ad2app-lib 1.17.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];
@@ -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
+ });