ad2app-lib 1.15.0 → 1.20.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.
@@ -35,6 +35,7 @@ export const EVENTS = {
35
35
  COMPOSE_STARTED: 'compose_started',
36
36
  POST_SCHEDULED: 'post_scheduled',
37
37
  POST_PUBLISHED: 'post_published', // server-confirmed (backend)
38
+ PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
38
39
  ANALYTICS_VIEWED: 'analytics_viewed',
39
40
 
40
41
  // Revenue (backend = source of truth). Paid-only product: no trial.
@@ -43,6 +44,48 @@ export const EVENTS = {
43
44
  CHECKOUT_STARTED: 'checkout_started',
44
45
  SUBSCRIPTION_STARTED: 'subscription_started',
45
46
  SUBSCRIPTION_CANCELED: 'subscription_canceled',
47
+
48
+ // Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
49
+ PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
50
+ PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
51
+ ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
52
+ EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
53
+ EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
54
+ EXIT_INTENT_CAPTURED: 'exit_intent_captured', // email captured into the beta list
55
+
56
+ // Measurement baseline (080) — subscription lifecycle (backend = source of truth)
57
+ BETA_SIGNUP_CONFIRMED: 'beta_signup_confirmed', // double-opt-in confirm click
58
+ PAYMENT_FAILED: 'payment_failed', // invoice.payment_failed (excl. unexecuted hard-decline retries)
59
+ DUNNING_RECOVERED: 'dunning_recovered', // open→paid after ≥1 failure on the same invoice
60
+ REFUND_ISSUED: 'refund_issued',
61
+ DISPUTE_OPENED: 'dispute_opened',
62
+ CANCEL_INITIATED: 'cancel_initiated', // start of the cancel funnel
63
+ RETENTION_OFFER_SHOWN: 'retention_offer_shown', // emitted ONLY when an offer is actually shown — no fabrication
64
+ CANCEL_COMPLETED: 'cancel_completed',
65
+
66
+ // Onboarding flow (promoted from the frontend-local onboardingEvents.ts into the
67
+ // governed taxonomy, 2026-07-19 — ONBOARDING_STEP_COMPLETED is defined above).
68
+ ONBOARDING_STARTED: 'onboarding_started',
69
+ ONBOARDING_COMPLETED: 'onboarding_completed',
70
+ ONBOARDING_SKIPPED: 'onboarding_skipped',
71
+
72
+ // Inbox engagement (tracking-coverage gap: the inbox surface had ZERO events).
73
+ COMMENT_REPLIED: 'comment_replied',
74
+ COMMENT_LIKED: 'comment_liked',
75
+ COMMENT_HIDDEN: 'comment_hidden',
76
+ COMMENT_DELETED: 'comment_deleted',
77
+ COMMENT_PRIVATE_REPLIED: 'comment_private_replied',
78
+ DM_REPLIED: 'dm_replied',
79
+ INBOX_MARKED_READ: 'inbox_marked_read', // sanctioned; wired when 082 mark-read lands
80
+
81
+ // Post lifecycle + account (tracking-coverage gap).
82
+ POST_EDITED: 'post_edited',
83
+ POST_DELETED: 'post_deleted',
84
+ POST_RESCHEDULED: 'post_rescheduled',
85
+ ACCOUNT_DISCONNECTED: 'account_disconnected',
86
+
87
+ // Settings.
88
+ NOTIFICATION_PREFERENCES_UPDATED: 'notification_preferences_updated',
46
89
  } as const;
47
90
 
48
91
  export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
@@ -54,6 +97,46 @@ export interface EmailEventProperties {
54
97
  link?: string;
55
98
  }
56
99
 
100
+ /**
101
+ * Where a paywall / upgrade / checkout moment was triggered from (080). A named,
102
+ * stable, closed set so the revenue funnel can attribute conversions to their
103
+ * origin surface. `checkout_abandon_email` is the recovery-email re-entry point
104
+ * (Superwall transaction-abandonment pattern). Wire values — do not rename.
105
+ */
106
+ export type TriggerSource =
107
+ | 'connect_wall'
108
+ | 'limit_reached'
109
+ | 'exit_intent'
110
+ | 'pricing_page'
111
+ | 'checkout_abandon_email'
112
+ | 'other';
113
+
114
+ /**
115
+ * The USER-CHOSEN i18n locale (080) — NOT `$browser_language`. Localization is
116
+ * the top structural conversion lever in the corpus (Adapty), so every wall /
117
+ * paywall insight slices by it. Stamped on the surfaces the emitter controls.
118
+ */
119
+ export type AppLocale = 'en' | 'pl';
120
+
121
+ /**
122
+ * Coarse failure-reason buckets for the `publish_failed` event (AD2-999).
123
+ *
124
+ * Deliberately a small, stable, closed set: it lets publish outcomes be sliced
125
+ * per failure class in PostHog WITHOUT putting raw provider (Zernio/platform)
126
+ * error text — which can carry user content or PII — on the event. The emitter
127
+ * (backend) maps a raw provider error string to exactly one of these; an
128
+ * absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
129
+ * they ship — they are wire values, historical data does not migrate.
130
+ */
131
+ export type PublishFailureReason =
132
+ | 'auth' // token expired / revoked / missing scope / permission denied
133
+ | 'rate_limit' // provider throttled us (429 / quota)
134
+ | 'media' // media rejected (format, size, duration, resolution)
135
+ | 'content' // caption/content rejected, duplicate, or policy violation
136
+ | 'network' // timeout / gateway / transport failure reaching the provider
137
+ | 'platform' // provider-side error (5xx / internal / unexpected)
138
+ | 'unknown'; // no error detail or unclassifiable
139
+
57
140
  /** Property shape per event. Keeps emitters honest across repos. */
58
141
  export interface EventProperties {
59
142
  [EVENTS.LANDING_CTA_CLICKED]: {
@@ -82,17 +165,47 @@ export interface EventProperties {
82
165
  [EVENTS.SOCIAL_ACCOUNT_CONNECTED]: {
83
166
  platform: string;
84
167
  account_count?: number;
168
+ // 080: 1 = first-ever account, 2 = second… — the "second action" activation hook.
169
+ connection_ordinal?: number;
85
170
  };
86
171
  [EVENTS.COMPOSE_STARTED]: { source?: 'wizard' | 'quick' };
87
172
  [EVENTS.POST_SCHEDULED]: {
88
173
  platforms: string[];
89
174
  platform_count: number;
90
175
  scheduled_for?: string;
176
+ // 080: nth scheduled post for this user (server-derived where owner=backend).
177
+ post_ordinal?: number;
91
178
  };
92
179
  [EVENTS.POST_PUBLISHED]: {
93
180
  platforms: string[];
94
181
  platform_count: number;
95
182
  status: 'success' | 'partial' | 'failed';
183
+ // 080: nth published post for this user (server-derived where owner=backend).
184
+ post_ordinal?: number;
185
+ // Single-platform attribution (AD2-999): set to the sole target platform
186
+ // when platform_count === 1, so a per-platform outcome is directly
187
+ // sliceable. Omitted for multi-platform posts, where the aggregate `status`
188
+ // plus the per-platform `publish_failed` events carry the breakdown.
189
+ // (AD2-1154 FR-5 also relies on this: Paweł's incident showed post_published
190
+ // firing with NO platform property, so a partial publish couldn't be sliced.)
191
+ platform?: string;
192
+ // The publish path that confirmed the outcome. Only the scheduled/reconcile
193
+ // path stamps this today; the immediate-publish path omits it (AD2-999).
194
+ source?: 'scheduled_reconcile';
195
+ // Optional per-platform outcome breakdown for a future consumer swap that
196
+ // fans post_published out once per platform (AD2-1154 FR-5; lib prep now,
197
+ // emission-site change stays a separate story per doctrine).
198
+ outcome?: 'success' | 'failed' | 'unknown';
199
+ reason_class?: PublishFailureReason;
200
+ };
201
+ // Per-platform publish failure (AD2-999). Emitted once per failed platform on
202
+ // a failed or partial publish (immediate path and scheduled reconcile), so a
203
+ // wholesale failure surfaces as explicit events, never a silent absence.
204
+ [EVENTS.PUBLISH_FAILED]: {
205
+ platform: string;
206
+ reason_class: PublishFailureReason;
207
+ post_id: string;
208
+ platform_count: number;
96
209
  };
97
210
  [EVENTS.ANALYTICS_VIEWED]: { scope?: 'overview' | 'account' | 'post' };
98
211
  [EVENTS.LIMIT_REACHED]: {
@@ -104,14 +217,83 @@ export interface EventProperties {
104
217
  from_plan?: string;
105
218
  to_plan?: string;
106
219
  surface: string;
220
+ trigger_source?: TriggerSource; // 080: revenue-funnel origin attribution
107
221
  };
108
- [EVENTS.CHECKOUT_STARTED]: { plan: string };
222
+ [EVENTS.CHECKOUT_STARTED]: { plan: string; trigger_source?: TriggerSource };
109
223
  [EVENTS.SUBSCRIPTION_STARTED]: {
110
224
  plan: string;
111
225
  mrr?: number;
112
226
  accounts_included?: number;
227
+ trigger_source?: TriggerSource; // 080
113
228
  };
114
229
  [EVENTS.SUBSCRIPTION_CANCELED]: { plan: string; reason?: string };
230
+
231
+ // ── Measurement baseline (080) — new event property shapes ──────────────────
232
+ // Paywall surfaces (frontend). hit_number is the analytical truth (an event
233
+ // property, NOT a person prop, since person props return latest-value for
234
+ // historical events). app_locale is the user-chosen i18n locale, required here.
235
+ [EVENTS.PAYWALL_SHOWN]: {
236
+ surface: 'upgrade_modal' | 'pricing_page';
237
+ trigger: 'user_click' | 'limit_reached' | 'connect_wall';
238
+ plan?: string;
239
+ hit_number: number;
240
+ dwell_ms?: number;
241
+ page_views?: number;
242
+ app_locale: AppLocale;
243
+ };
244
+ [EVENTS.PAYWALL_DISMISSED]: {
245
+ surface: 'upgrade_modal' | 'pricing_page';
246
+ trigger: 'user_click' | 'limit_reached' | 'connect_wall';
247
+ hit_number: number;
248
+ app_locale: AppLocale;
249
+ };
250
+ [EVENTS.ONBOARDING_STEP_COMPLETED]: {
251
+ flow: 'complete_profile'; // closed set, extensible when a new flow ships
252
+ step: string; // stable slug derived from the real wizard, not guessed
253
+ step_index: number; // 1-based
254
+ app_locale: AppLocale;
255
+ };
256
+ // Exit-intent (065). surface value is VERBATIM from the ExitIntentModal
257
+ // component ('connect_wall'); dwell_ms / page_views / app_locale are optional
258
+ // additions T003 wires (the component does not emit them yet — fidelity gate).
259
+ [EVENTS.EXIT_INTENT_SHOWN]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
260
+ [EVENTS.EXIT_INTENT_DISMISSED]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
261
+ [EVENTS.EXIT_INTENT_CAPTURED]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
262
+ // Subscription lifecycle (backend). days_since_start / attempts / days_late are
263
+ // server-derived; retention_offer_shown emits ONLY when an offer is really shown.
264
+ [EVENTS.BETA_SIGNUP_CONFIRMED]: { source: 'exit_intent' | 'free_skills' };
265
+ [EVENTS.PAYMENT_FAILED]: { plan: string; attempt: number };
266
+ [EVENTS.DUNNING_RECOVERED]: { plan: string; attempts: number; days_late: number };
267
+ [EVENTS.REFUND_ISSUED]: { plan: string; days_since_start: number };
268
+ [EVENTS.DISPUTE_OPENED]: { plan: string; days_since_start: number };
269
+ [EVENTS.CANCEL_INITIATED]: { plan: string; days_since_start: number };
270
+ [EVENTS.RETENTION_OFFER_SHOWN]: { plan: string; offer: string };
271
+ [EVENTS.CANCEL_COMPLETED]: { plan: string; days_since_start: number; reason?: string };
272
+
273
+ // Onboarding flow (promoted 2026-07-19). flow defaults to the main app onboarding.
274
+ [EVENTS.ONBOARDING_STARTED]: { flow?: string };
275
+ [EVENTS.ONBOARDING_COMPLETED]: { flow?: string; steps_completed?: number };
276
+ [EVENTS.ONBOARDING_SKIPPED]: { flow?: string; step?: string };
277
+
278
+ // Inbox engagement — platform is the social platform the comment/DM is on.
279
+ // `success` is emitted on BOTH the success and failure path so a broken action
280
+ // is visible (slice by success=false), not a silent absence.
281
+ [EVENTS.COMMENT_REPLIED]: { platform?: string; success: boolean };
282
+ [EVENTS.COMMENT_LIKED]: { platform?: string; liked: boolean; success: boolean };
283
+ [EVENTS.COMMENT_HIDDEN]: { platform?: string; hidden: boolean; success: boolean };
284
+ [EVENTS.COMMENT_DELETED]: { platform?: string; success: boolean };
285
+ [EVENTS.COMMENT_PRIVATE_REPLIED]: { platform?: string; success: boolean };
286
+ [EVENTS.DM_REPLIED]: { platform?: string; success: boolean };
287
+ [EVENTS.INBOX_MARKED_READ]: { platform?: string; success: boolean };
288
+
289
+ // Post lifecycle + account.
290
+ [EVENTS.POST_EDITED]: { platforms?: string[]; platform_count?: number; success: boolean };
291
+ [EVENTS.POST_DELETED]: { platform_count?: number; success: boolean };
292
+ [EVENTS.POST_RESCHEDULED]: { platform_count?: number; success: boolean };
293
+ [EVENTS.ACCOUNT_DISCONNECTED]: { platform: string; success: boolean };
294
+
295
+ // Settings — which preference toggled and its new state.
296
+ [EVENTS.NOTIFICATION_PREFERENCES_UPDATED]: { preference?: string; enabled?: boolean; success: boolean };
115
297
  }
116
298
 
117
299
  /** Canonical person property keys (set via identify / $set). */
@@ -120,6 +302,10 @@ export const PERSON_PROPS = {
120
302
  ROLE: 'role',
121
303
  PLAN: 'plan', // 'free' | 'starter' | 'pro' (SchedulingSubscriptionTier)
122
304
  ACCOUNT_COUNT: 'account_count',
305
+ // 080: convenience $set mirror of paywall exposure. The event-level `hit_number`
306
+ // is the analytical truth — person props are query-time-latest here, so a counter
307
+ // stored here would rewrite the value on every historical paywall_shown event.
308
+ PAYWALL_HITS: 'paywall_hits',
123
309
  } as const;
124
310
 
125
311
  export type PersonPropKey = (typeof PERSON_PROPS)[keyof typeof PERSON_PROPS];
@@ -90,7 +90,7 @@ export const PRIVACY_SECTIONS: LegalSection[] = [
90
90
  items: [
91
91
  { text: '**Account data:** for the duration of your account plus a 30-day recovery window after you delete it. During the window your account is deactivated and your data (profile, scheduled posts, drafts, settings, analytics history) is held solely so the account can be restored if the deletion was a mistake. After 30 days a scheduled job permanently and irreversibly purges it. You may request immediate permanent deletion, waiving the recovery window, by explicitly stating this in a request to {EMAIL}.' },
92
92
  { text: '**What is immediate and irreversible even during the recovery window:** when you delete your account, we immediately disconnect your connected social accounts and revoke the associated access at our publishing processor Zernio. This is not undone by recovering your account; if you restore your account within the 30 days, your ad2app data comes back but you must reconnect your social platforms yourself.' },
93
- { text: '**What account deletion does not do:** deleting your ad2app account does not delete the posts you already published to your social media platforms; that content lives on those platforms under their terms, and you manage or delete it there. Our deletion and revocation requests to Zernio (including removal of the profile container Zernio maintains for your account) are sent immediately; if a request fails we retry it until it succeeds, so completion on Zernio\'s side can lag briefly behind your deletion. Once our disconnect and deletion requests reach Zernio, our data processing agreement with them requires deletion of all copies within 10 business days of the cessation of the relevant services, with written certification of completion.' },
93
+ { text: '**What account deletion does not do:** deleting your ad2app account does not delete the posts you already published to your social media platforms; that content lives on those platforms under their terms, and you manage or delete it there. Our deletion and revocation requests to Zernio (including removal of the profile container Zernio maintains for your account) are sent immediately; if a request fails we retry it, so completion on Zernio\'s side can lag briefly behind your deletion. Separately, at the end of our overall relationship with Zernio, our data processing agreement requires them to delete all remaining copies within 10 business days of the cessation of services, with written certification of completion.' },
94
94
  { text: '**Post content, scheduled posts, and published-post analytics:** retained for the duration of your account and deleted with your account data (subject to the 30-day recovery window above). Anonymised aggregated analytics may be retained indefinitely.' },
95
95
  { text: '**OAuth tokens:** for platforms where Zernio holds your tokens on our behalf, disconnecting an account or deleting your ad2app account triggers an immediate disconnect and revocation request to Zernio (with retries on failure, as above). Any tokens we hold ourselves are deleted immediately on disconnect or account deletion, with no recovery window.' },
96
96
  { text: '**Inbox data (DMs and comments):** retained for the duration of your account; deleted with your account data (subject to the 30-day recovery window).' },
@@ -1,6 +1,19 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { SchedulingInboxCommentDTO } from './I_SchedulingInbox';
3
+ import {
4
+ SchedulingInboxCommentDTO,
5
+ SchedulingInboxMentionDTO,
6
+ SchedulingReplyToMentionDTO,
7
+ SchedulingInboxReviewDTO,
8
+ SchedulingReplyToReviewDTO,
9
+ SchedulingInboxMessageDTO,
10
+ SchedulingInboxMessagePageDTO,
11
+ SchedulingCommentModerationDTO,
12
+ SchedulingInboxCapabilityDTO,
13
+ SchedulingCreateConversationDTO,
14
+ SchedulingConversationReadDTO,
15
+ SchedulingCrossPostCommentDTO,
16
+ } from './I_SchedulingInbox';
4
17
 
5
18
  // AD2-1088 — engagement + ownership pass-through on inbox comments, verbatim
6
19
  // from Zernio GET /v1/inbox/comments/{postId} (OpenAPI v1.0.4:20799). Set
@@ -53,3 +66,207 @@ test('SchedulingInboxCommentDTO nests replies with the same optional fields', ()
53
66
  });
54
67
  assert.equal(comment.replies?.[0].parentId, 'c1');
55
68
  });
69
+
70
+ // ── 082 Inbox Parity — new inbox surfaces (US1) ───────────────────────────────
71
+
72
+ // canModerate is YouTube-only (OpenAPI v1.0.4:22489); absent for everything else,
73
+ // never fabricated to false-as-if-known.
74
+ test('SchedulingInboxCommentDTO.canModerate is absent by default and pass-through when set', () => {
75
+ const plain = new SchedulingInboxCommentDTO({
76
+ id: 'c1',
77
+ authorName: 'Jane',
78
+ text: 'hi',
79
+ createdAt: '2026-07-19T00:00:00.000Z',
80
+ });
81
+ assert.equal(plain.canModerate, undefined);
82
+ const yt = new SchedulingInboxCommentDTO({
83
+ id: 'c2',
84
+ authorName: 'Jane',
85
+ text: 'hi',
86
+ createdAt: '2026-07-19T00:00:00.000Z',
87
+ canModerate: true,
88
+ });
89
+ assert.equal(yt.canModerate, true);
90
+ });
91
+
92
+ // Mentions — GET /v1/inbox/mentions (23108); reply body [accountId, mediaId, message] (23207).
93
+ test('SchedulingInboxMentionDTO keeps source fields optional (honest-or-absent)', () => {
94
+ const bare = new SchedulingInboxMentionDTO({
95
+ id: 'm1',
96
+ platform: 'instagram',
97
+ accountId: 'a1',
98
+ authorName: 'fan',
99
+ text: '@me great post',
100
+ createdAt: '2026-07-19T00:00:00.000Z',
101
+ });
102
+ assert.equal(bare.sourcePostId, undefined);
103
+ assert.equal(bare.sourcePostUrl, undefined);
104
+ const full = new SchedulingInboxMentionDTO({
105
+ id: 'm2',
106
+ platform: 'instagram',
107
+ accountId: 'a1',
108
+ authorName: 'fan',
109
+ text: '@me nice',
110
+ createdAt: '2026-07-19T00:00:00.000Z',
111
+ sourcePostId: 'p9',
112
+ sourcePostUrl: 'https://example/p9',
113
+ });
114
+ assert.equal(full.sourcePostId, 'p9');
115
+ assert.equal(full.sourcePostUrl, 'https://example/p9');
116
+ });
117
+
118
+ test('SchedulingReplyToMentionDTO requires accountId, mediaId and message', () => {
119
+ const dto = new SchedulingReplyToMentionDTO({
120
+ accountId: 'a1',
121
+ mediaId: 'md1',
122
+ message: 'thanks!',
123
+ });
124
+ assert.equal(dto.accountId, 'a1');
125
+ assert.equal(dto.mediaId, 'md1');
126
+ assert.equal(dto.message, 'thanks!');
127
+ });
128
+
129
+ // Reviews — GET /v1/inbox/reviews (23269); reply POST /v1/inbox/reviews/{reviewId}/reply (23377).
130
+ test('SchedulingInboxReviewDTO carries rating + optional replied flag', () => {
131
+ const r = new SchedulingInboxReviewDTO({
132
+ id: 'rv1',
133
+ platform: 'googlebusiness',
134
+ accountId: 'a1',
135
+ authorName: 'Customer',
136
+ rating: 5,
137
+ text: 'great',
138
+ createdAt: '2026-07-19T00:00:00.000Z',
139
+ });
140
+ assert.equal(r.rating, 5);
141
+ assert.equal(r.replied, undefined);
142
+ const replied = new SchedulingInboxReviewDTO({
143
+ id: 'rv2',
144
+ platform: 'googlebusiness',
145
+ accountId: 'a1',
146
+ authorName: 'Customer',
147
+ rating: 4,
148
+ text: 'good',
149
+ createdAt: '2026-07-19T00:00:00.000Z',
150
+ replied: true,
151
+ });
152
+ assert.equal(replied.replied, true);
153
+ });
154
+
155
+ test('SchedulingReplyToReviewDTO carries reviewId, accountId, text', () => {
156
+ const dto = new SchedulingReplyToReviewDTO({
157
+ reviewId: 'rv1',
158
+ accountId: 'a1',
159
+ text: 'thank you',
160
+ });
161
+ assert.equal(dto.reviewId, 'rv1');
162
+ assert.equal(dto.accountId, 'a1');
163
+ assert.equal(dto.text, 'thank you');
164
+ });
165
+
166
+ // Cursor pagination — kills the 100-message thread truncation.
167
+ test('SchedulingInboxMessageDTO + page keep nextCursor optional', () => {
168
+ const msg = new SchedulingInboxMessageDTO({
169
+ id: 'msg1',
170
+ conversationId: 'conv1',
171
+ authorId: 'u1',
172
+ authorName: 'fan',
173
+ text: 'hi',
174
+ createdAt: '2026-07-19T00:00:00.000Z',
175
+ isOwn: false,
176
+ });
177
+ const lastPage = new SchedulingInboxMessagePageDTO({ items: [msg] });
178
+ assert.equal(lastPage.items.length, 1);
179
+ assert.equal(lastPage.nextCursor, undefined);
180
+ const midPage = new SchedulingInboxMessagePageDTO({ items: [msg], nextCursor: 'cur2' });
181
+ assert.equal(midPage.nextCursor, 'cur2');
182
+ });
183
+
184
+ // Moderation — closed set [published, rejected, heldForReview]; banAuthor only with rejected (22489).
185
+ test('SchedulingCommentModerationDTO carries a closed-set status and optional banAuthor', () => {
186
+ const approve = new SchedulingCommentModerationDTO({ moderationStatus: 'published' });
187
+ assert.equal(approve.moderationStatus, 'published');
188
+ assert.equal(approve.banAuthor, undefined);
189
+ const reject = new SchedulingCommentModerationDTO({ moderationStatus: 'rejected', banAuthor: true });
190
+ assert.equal(reject.moderationStatus, 'rejected');
191
+ assert.equal(reject.banAuthor, true);
192
+ });
193
+
194
+ // Per-platform capability — drives honest show/hide (FR-4); absent/false = surface hidden.
195
+ test('SchedulingInboxCapabilityDTO exposes per-surface booleans', () => {
196
+ const cap = new SchedulingInboxCapabilityDTO({
197
+ platform: 'youtube',
198
+ dm: false,
199
+ comments: true,
200
+ mentions: false,
201
+ reviews: false,
202
+ dmInit: false,
203
+ moderation: true,
204
+ });
205
+ assert.equal(cap.comments, true);
206
+ assert.equal(cap.moderation, true);
207
+ assert.equal(cap.dm, false);
208
+ });
209
+
210
+ test('SchedulingCreateConversationDTO carries account, recipient, text', () => {
211
+ const dto = new SchedulingCreateConversationDTO({
212
+ accountId: 'a1',
213
+ recipientId: 'r1',
214
+ text: 'hello',
215
+ });
216
+ assert.equal(dto.accountId, 'a1');
217
+ assert.equal(dto.recipientId, 'r1');
218
+ assert.equal(dto.text, 'hello');
219
+ });
220
+
221
+ // Mark-read acknowledgement — unreadCount absent unless Zernio reports it.
222
+ test('SchedulingConversationReadDTO keeps unreadCount optional', () => {
223
+ const ack = new SchedulingConversationReadDTO({ conversationId: 'conv1' });
224
+ assert.equal(ack.conversationId, 'conv1');
225
+ assert.equal(ack.unreadCount, undefined);
226
+ const ackWithCount = new SchedulingConversationReadDTO({ conversationId: 'conv1', unreadCount: 0 });
227
+ assert.equal(ackWithCount.unreadCount, 0);
228
+ });
229
+
230
+ // 082 US3 (T301 / AD2-1110): comments re-sourced from Zernio cross-post carry their OWN post
231
+ // attribution so an uncached post still surfaces its comments; the cache is enrichment only.
232
+ test('SchedulingCrossPostCommentDTO carries post attribution; enrichment + engagement absent by default', () => {
233
+ const c = new SchedulingCrossPostCommentDTO({
234
+ id: 'x1', authorName: 'Jane', text: 'nice', createdAt: '2026-07-19T00:00:00.000Z',
235
+ postId: 'p1', platform: 'instagram', accountId: 'acc1',
236
+ });
237
+ assert.equal(c.postId, 'p1');
238
+ assert.equal(c.platform, 'instagram');
239
+ assert.equal(c.accountId, 'acc1');
240
+ assert.equal(c.postContentSnippet, undefined, 'enrichment absent, never backfilled');
241
+ assert.equal(c.postThumbnailUrl, undefined);
242
+ assert.equal(c.likeCount, undefined);
243
+ assert.equal(c.canModerate, undefined);
244
+ });
245
+
246
+ test('SchedulingCrossPostCommentDTO fills enrichment + engagement when provided', () => {
247
+ const c = new SchedulingCrossPostCommentDTO({
248
+ id: 'x2', authorName: 'Jane', text: 'nice', createdAt: '2026-07-19T00:00:00.000Z',
249
+ postId: 'p2', platform: 'youtube', accountId: 'acc2',
250
+ postContentSnippet: 'My launch post', postThumbnailUrl: 'https://cdn/x.jpg',
251
+ likeCount: 5, replyCount: 2, isOwner: false, canModerate: true,
252
+ });
253
+ assert.equal(c.postContentSnippet, 'My launch post');
254
+ assert.equal(c.postThumbnailUrl, 'https://cdn/x.jpg');
255
+ assert.equal(c.likeCount, 5);
256
+ assert.equal(c.replyCount, 2);
257
+ assert.equal(c.canModerate, true);
258
+ });
259
+
260
+ test('SchedulingCrossPostCommentDTO nests replies of its own type', () => {
261
+ const c = new SchedulingCrossPostCommentDTO({
262
+ id: 'x3', authorName: 'Jane', text: 'top', createdAt: '2026-07-19T00:00:00.000Z',
263
+ postId: 'p3', platform: 'instagram', accountId: 'acc3',
264
+ replies: [new SchedulingCrossPostCommentDTO({
265
+ id: 'x3r1', authorName: 'Bob', text: 'reply', createdAt: '2026-07-19T00:01:00.000Z',
266
+ postId: 'p3', platform: 'instagram', accountId: 'acc3', parentId: 'x3',
267
+ })],
268
+ });
269
+ assert.equal(c.replies?.length, 1);
270
+ assert.equal(c.replies?.[0].parentId, 'x3');
271
+ assert.equal(c.replies?.[0].postId, 'p3', 'nested reply keeps its post attribution');
272
+ });