ad2app-lib 1.10.0 → 1.14.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,116 @@
1
+ /**
2
+ * Contract tests for the canonical publish-limits table (AD2-1140 Wave 1).
3
+ *
4
+ * These lock the shape every consumer (frontend compose, backend media
5
+ * upload validation) will depend on in Wave 2: all 10 platforms present,
6
+ * every field positive and correctly typed, and the universal upload cap
7
+ * pinned to the value ad2app-backend actually enforces.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+
12
+ import { ALL_PUBLISH_PLATFORMS, MAX_UPLOAD_BYTES, PUBLISH_LIMITS } from './index';
13
+ import type { PublishPlatform } from './types';
14
+
15
+ const EXPECTED_PLATFORMS: PublishPlatform[] = [
16
+ 'twitter',
17
+ 'instagram',
18
+ 'tiktok',
19
+ 'linkedin',
20
+ 'facebook',
21
+ 'youtube',
22
+ 'threads',
23
+ 'reddit',
24
+ 'pinterest',
25
+ 'bluesky',
26
+ ];
27
+
28
+ test('PUBLISH_LIMITS has exactly the 10 expected platform keys', () => {
29
+ const keys = Object.keys(PUBLISH_LIMITS).sort();
30
+ assert.deepEqual(keys, [...EXPECTED_PLATFORMS].sort());
31
+ assert.equal(keys.length, 10);
32
+ });
33
+
34
+ test('ALL_PUBLISH_PLATFORMS matches the PUBLISH_LIMITS keys', () => {
35
+ assert.deepEqual([...ALL_PUBLISH_PLATFORMS].sort(), Object.keys(PUBLISH_LIMITS).sort());
36
+ });
37
+
38
+ test('every platform has a complete, correctly typed, positive limits object', () => {
39
+ for (const platform of EXPECTED_PLATFORMS) {
40
+ const limits = PUBLISH_LIMITS[platform];
41
+ assert.ok(limits, `missing PUBLISH_LIMITS entry for ${platform}`);
42
+
43
+ assert.equal(typeof limits.charLimit, 'number', `${platform}.charLimit not a number`);
44
+ assert.ok(limits.charLimit > 0, `${platform}.charLimit must be positive`);
45
+
46
+ assert.equal(typeof limits.maxImages, 'number', `${platform}.maxImages not a number`);
47
+ assert.ok(limits.maxImages >= 0, `${platform}.maxImages must be >= 0`);
48
+
49
+ assert.equal(typeof limits.maxVideos, 'number', `${platform}.maxVideos not a number`);
50
+ assert.ok(limits.maxVideos >= 0, `${platform}.maxVideos must be >= 0`);
51
+
52
+ assert.equal(
53
+ typeof limits.maxVideoDurationSec,
54
+ 'number',
55
+ `${platform}.maxVideoDurationSec not a number`,
56
+ );
57
+ assert.ok(limits.maxVideoDurationSec > 0, `${platform}.maxVideoDurationSec must be positive`);
58
+
59
+ assert.equal(typeof limits.acceptsVideo, 'boolean', `${platform}.acceptsVideo not a boolean`);
60
+ assert.equal(
61
+ typeof limits.acceptsImages,
62
+ 'boolean',
63
+ `${platform}.acceptsImages not a boolean`,
64
+ );
65
+
66
+ // A platform that doesn't accept a media type should have a 0 count for it,
67
+ // and vice versa — catches copy-paste drift between the flag and the count.
68
+ assert.equal(
69
+ limits.acceptsImages,
70
+ limits.maxImages > 0,
71
+ `${platform}: acceptsImages must agree with maxImages > 0`,
72
+ );
73
+ assert.equal(
74
+ limits.acceptsVideo,
75
+ limits.maxVideos > 0,
76
+ `${platform}: acceptsVideo must agree with maxVideos > 0`,
77
+ );
78
+ }
79
+ });
80
+
81
+ test('youtube is the sole image-less platform (acceptsImages=false, maxImages=0)', () => {
82
+ assert.equal(PUBLISH_LIMITS.youtube.acceptsImages, false);
83
+ assert.equal(PUBLISH_LIMITS.youtube.maxImages, 0);
84
+ for (const platform of EXPECTED_PLATFORMS.filter((p) => p !== 'youtube')) {
85
+ assert.equal(PUBLISH_LIMITS[platform].acceptsImages, true, `${platform} should accept images`);
86
+ }
87
+ });
88
+
89
+ test('MAX_UPLOAD_BYTES is pinned to 500MB — the ad2app-backend enforced universal cap', () => {
90
+ assert.equal(MAX_UPLOAD_BYTES, 500 * 1024 * 1024);
91
+ });
92
+
93
+ /**
94
+ * Drift guard: MAX_UPLOAD_BYTES is a single universal cap, not a per-platform
95
+ * value — this table intentionally does NOT carry per-platform max
96
+ * image/video byte sizes (the old platformConstants.ts
97
+ * metadata.mediaConstraints.maxImageSize/maxVideoSize were dead/unenforced
98
+ * values this feature retires). Wave 2 adds a consumer-side test in
99
+ * ad2app-frontend and ad2app-backend asserting their enforced upload cap
100
+ * equals ad2app-lib/publish-limits MAX_UPLOAD_BYTES, so this table stays the
101
+ * single source of truth rather than three independently-drifting copies.
102
+ */
103
+ test('PlatformPublishLimits contract does not carry per-platform byte-size fields', () => {
104
+ const keys = Object.keys(PUBLISH_LIMITS.instagram);
105
+ assert.deepEqual(
106
+ keys.sort(),
107
+ [
108
+ 'acceptsImages',
109
+ 'acceptsVideo',
110
+ 'charLimit',
111
+ 'maxImages',
112
+ 'maxVideoDurationSec',
113
+ 'maxVideos',
114
+ ].sort(),
115
+ );
116
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Canonical publish-limits source of truth for ad2app (AD2-1140 Wave 1).
3
+ *
4
+ * Single source of truth for (a) the per-platform compose/publish limits
5
+ * (character count, image/video counts, max video duration) and (b) the
6
+ * universal media-upload byte cap ad2app itself enforces.
7
+ *
8
+ * Provenance:
9
+ * - Per-platform values are the CORRECTED figures from
10
+ * ad2app-frontend/src/components/social/compose/platformConstants.ts
11
+ * (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
12
+ * acceptsImages / metadata.mediaConstraints.maxVideoDuration).
13
+ * - MAX_UPLOAD_BYTES matches ad2app-backend
14
+ * src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
15
+ *
16
+ * Wave 2 (separate PRs) makes ad2app-frontend and ad2app-backend import from
17
+ * here instead of carrying their own copies, and deletes the stale
18
+ * per-platform maxImageSize/maxVideoSize byte values that this table
19
+ * deliberately does NOT carry forward (they were never the real enforced
20
+ * cap — MAX_UPLOAD_BYTES is).
21
+ */
22
+
23
+ import type { PlatformPublishLimits, PublishPlatform } from './types';
24
+
25
+ export type { PlatformPublishLimits, PublishPlatform } from './types';
26
+
27
+ /**
28
+ * The universal media-upload byte cap ad2app enforces on every publish,
29
+ * regardless of destination platform. This is ad2app's own operational
30
+ * choice (all media flows through the /late/uploadMedia path), NOT a
31
+ * per-platform maximum — do not read this as "what X/Instagram/etc. allow."
32
+ */
33
+ export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
34
+
35
+ /** Per-platform compose/publish limits for all 10 connectable platforms. */
36
+ export const PUBLISH_LIMITS: Record<PublishPlatform, PlatformPublishLimits> = {
37
+ twitter: {
38
+ charLimit: 280,
39
+ maxImages: 4,
40
+ maxVideos: 1,
41
+ maxVideoDurationSec: 3600,
42
+ acceptsVideo: true,
43
+ acceptsImages: true,
44
+ },
45
+ instagram: {
46
+ charLimit: 2200,
47
+ maxImages: 10,
48
+ maxVideos: 1,
49
+ maxVideoDurationSec: 3600,
50
+ acceptsVideo: true,
51
+ acceptsImages: true,
52
+ },
53
+ tiktok: {
54
+ charLimit: 2200,
55
+ maxImages: 35,
56
+ maxVideos: 1,
57
+ maxVideoDurationSec: 600,
58
+ acceptsVideo: true,
59
+ acceptsImages: true,
60
+ },
61
+ linkedin: {
62
+ charLimit: 3000,
63
+ maxImages: 9,
64
+ maxVideos: 1,
65
+ maxVideoDurationSec: 3600,
66
+ acceptsVideo: true,
67
+ acceptsImages: true,
68
+ },
69
+ facebook: {
70
+ charLimit: 63206,
71
+ maxImages: 10,
72
+ maxVideos: 1,
73
+ maxVideoDurationSec: 3600,
74
+ acceptsVideo: true,
75
+ acceptsImages: true,
76
+ },
77
+ youtube: {
78
+ charLimit: 5000,
79
+ maxImages: 0,
80
+ maxVideos: 1,
81
+ maxVideoDurationSec: 43200,
82
+ acceptsVideo: true,
83
+ acceptsImages: false,
84
+ },
85
+ threads: {
86
+ charLimit: 500,
87
+ maxImages: 10,
88
+ maxVideos: 1,
89
+ maxVideoDurationSec: 300,
90
+ acceptsVideo: true,
91
+ acceptsImages: true,
92
+ },
93
+ reddit: {
94
+ charLimit: 40000,
95
+ maxImages: 20,
96
+ maxVideos: 1,
97
+ maxVideoDurationSec: 3600,
98
+ acceptsVideo: true,
99
+ acceptsImages: true,
100
+ },
101
+ pinterest: {
102
+ charLimit: 500,
103
+ maxImages: 1,
104
+ maxVideos: 1,
105
+ maxVideoDurationSec: 900,
106
+ acceptsVideo: true,
107
+ acceptsImages: true,
108
+ },
109
+ bluesky: {
110
+ charLimit: 300,
111
+ maxImages: 4,
112
+ maxVideos: 1,
113
+ maxVideoDurationSec: 60,
114
+ acceptsVideo: true,
115
+ acceptsImages: true,
116
+ },
117
+ };
118
+
119
+ /** All publish platforms, canonical order (mirrors PUBLISH_LIMITS key order). */
120
+ export const ALL_PUBLISH_PLATFORMS: PublishPlatform[] = Object.keys(
121
+ PUBLISH_LIMITS,
122
+ ) as PublishPlatform[];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Publish-limits domain — types.
3
+ *
4
+ * AD2-1140 Wave 1: canonicalizes the per-platform compose/publish limits that
5
+ * previously lived only as a hand-copied constant in ad2app-frontend
6
+ * (platformConstants.ts) plus the universal media-size cap enforced in
7
+ * ad2app-backend (late-media-upload.service.ts). Wave 2 makes both consumers
8
+ * import from here instead of carrying their own copies.
9
+ */
10
+
11
+ /** The 10 platforms ad2app can publish to. Matches the frontend's SocialPlatform union. */
12
+ export type PublishPlatform =
13
+ | 'twitter'
14
+ | 'instagram'
15
+ | 'tiktok'
16
+ | 'linkedin'
17
+ | 'facebook'
18
+ | 'youtube'
19
+ | 'threads'
20
+ | 'reddit'
21
+ | 'pinterest'
22
+ | 'bluesky';
23
+
24
+ /** Per-platform compose/publish limits (content, not file-size — see MAX_UPLOAD_BYTES for that). */
25
+ export interface PlatformPublishLimits {
26
+ /** Max caption/body length in characters. */
27
+ charLimit: number;
28
+ /** Max still images per post (0 = platform does not accept images). */
29
+ maxImages: number;
30
+ /** Max videos per post (0 = platform does not accept video). */
31
+ maxVideos: number;
32
+ /** Max video duration in seconds. */
33
+ maxVideoDurationSec: number;
34
+ /** Whether the platform accepts video media at all. */
35
+ acceptsVideo: boolean;
36
+ /** Whether the platform accepts image media at all. */
37
+ acceptsImages: boolean;
38
+ }
@@ -0,0 +1,91 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ ANALYTICS_SOURCES,
5
+ SchedulingAnalyticsKpiDTO,
6
+ } from './I_SchedulingAnalytics';
7
+
8
+ // AD2-1045 — the analytics partial-failure signal: a KPI response can name
9
+ // which sources were unavailable so a swallowed Zernio failure is never
10
+ // presented as real zeros.
11
+
12
+ test('ANALYTICS_SOURCES names the four analytics sources', () => {
13
+ assert.deepEqual(
14
+ [...ANALYTICS_SOURCES],
15
+ ['daily-metrics', 'follower-stats', 'content-decay', 'top-posts'],
16
+ );
17
+ });
18
+
19
+ test('SchedulingAnalyticsKpiDTO carries engagements and views (AD2-1078 headline metrics)', () => {
20
+ const kpi = new SchedulingAnalyticsKpiDTO({
21
+ impressions: 10,
22
+ reach: 5,
23
+ engagements: 42,
24
+ views: 100,
25
+ engagementRate: 1.2,
26
+ followerGrowth: 3,
27
+ });
28
+ assert.equal(kpi.engagements, 42);
29
+ assert.equal(kpi.views, 100);
30
+ });
31
+
32
+ test('SchedulingAnalyticsKpiDTO carries failedSources when provided', () => {
33
+ const kpi = new SchedulingAnalyticsKpiDTO({
34
+ impressions: 10,
35
+ reach: 5,
36
+ engagements: 4,
37
+ views: 20,
38
+ engagementRate: 1.2,
39
+ followerGrowth: 3,
40
+ failedSources: ['follower-stats'],
41
+ });
42
+ assert.deepEqual(kpi.failedSources, ['follower-stats']);
43
+ });
44
+
45
+ test('failedSources is optional and absent by default (backward compatible)', () => {
46
+ const kpi = new SchedulingAnalyticsKpiDTO({
47
+ impressions: 10,
48
+ reach: 5,
49
+ engagements: 4,
50
+ views: 20,
51
+ engagementRate: 1.2,
52
+ followerGrowth: 3,
53
+ });
54
+ assert.equal(kpi.failedSources, undefined);
55
+ assert.equal('failedSources' in JSON.parse(JSON.stringify(kpi)), false);
56
+ });
57
+
58
+ // AD2-1078 — structural (permanent, platform-level) metric unavailability,
59
+ // distinct from AD2-1045's transient failedSources: Instagram Graph API v22+
60
+ // dropped impressions, TikTok never exposed reach at all.
61
+
62
+ test('SchedulingAnalyticsKpiDTO carries impressionsAvailable/reachAvailable when provided', () => {
63
+ const kpi = new SchedulingAnalyticsKpiDTO({
64
+ impressions: 0,
65
+ reach: 5,
66
+ engagements: 4,
67
+ views: 20,
68
+ engagementRate: 1.2,
69
+ followerGrowth: 3,
70
+ impressionsAvailable: false,
71
+ reachAvailable: true,
72
+ });
73
+ assert.equal(kpi.impressionsAvailable, false);
74
+ assert.equal(kpi.reachAvailable, true);
75
+ });
76
+
77
+ test('impressionsAvailable/reachAvailable are optional and absent by default (backward compatible)', () => {
78
+ const kpi = new SchedulingAnalyticsKpiDTO({
79
+ impressions: 10,
80
+ reach: 5,
81
+ engagements: 4,
82
+ views: 20,
83
+ engagementRate: 1.2,
84
+ followerGrowth: 3,
85
+ });
86
+ assert.equal(kpi.impressionsAvailable, undefined);
87
+ assert.equal(kpi.reachAvailable, undefined);
88
+ const serialized = JSON.parse(JSON.stringify(kpi));
89
+ assert.equal('impressionsAvailable' in serialized, false);
90
+ assert.equal('reachAvailable' in serialized, false);
91
+ });
@@ -5,6 +5,21 @@
5
5
  * per-post timeline snapshots, content decay windows, and follower stats.
6
6
  */
7
7
 
8
+ // ── Analytics sources (AD2-1045) ──────────────────────────────────────────────
9
+
10
+ /**
11
+ * The upstream sources an analytics response aggregates. Used by
12
+ * `failedSources` to name which of them were unavailable.
13
+ */
14
+ export const ANALYTICS_SOURCES = [
15
+ 'daily-metrics',
16
+ 'follower-stats',
17
+ 'content-decay',
18
+ 'top-posts',
19
+ ] as const;
20
+
21
+ export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
22
+
8
23
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
9
24
 
10
25
  /**
@@ -14,14 +29,50 @@
14
29
  export class SchedulingAnalyticsKpiDTO {
15
30
  impressions: number;
16
31
  reach: number;
32
+ /** Total engagements (likes, comments, shares, saves) across all selected platforms. */
33
+ engagements: number;
34
+ /** Total video/media views across all selected platforms. */
35
+ views: number;
17
36
  engagementRate: number;
18
37
  followerGrowth: number;
38
+ /**
39
+ * Sources that were unavailable when this response was assembled
40
+ * (AD2-1045). Absent/empty = all sources healthy. When present, the
41
+ * numeric fields contain only data from the healthy sources — a partial
42
+ * outage must never read as real zeros.
43
+ */
44
+ failedSources?: AnalyticsSource[];
45
+ /**
46
+ * Structural availability of the impressions metric (AD2-1078) — distinct
47
+ * from `failedSources`, which is a transient per-fetch outage. False when
48
+ * the connected platform mix fundamentally does not expose impressions
49
+ * (e.g. Instagram Graph API v22+ dropped it): an honest "—" beats a fake
50
+ * 0, which reads as "zero people saw this". Absent/true = available.
51
+ */
52
+ impressionsAvailable?: boolean;
53
+ /**
54
+ * Structural availability of the reach metric (AD2-1078). False when the
55
+ * connected platform mix fundamentally never exposes reach (e.g. TikTok).
56
+ * Absent/true = available.
57
+ */
58
+ reachAvailable?: boolean;
19
59
 
20
60
  constructor(data: SchedulingAnalyticsKpiDTO) {
21
61
  this.impressions = data.impressions;
22
62
  this.reach = data.reach;
63
+ this.engagements = data.engagements;
64
+ this.views = data.views;
23
65
  this.engagementRate = data.engagementRate;
24
66
  this.followerGrowth = data.followerGrowth;
67
+ if (data.failedSources !== undefined) {
68
+ this.failedSources = data.failedSources;
69
+ }
70
+ if (data.impressionsAvailable !== undefined) {
71
+ this.impressionsAvailable = data.impressionsAvailable;
72
+ }
73
+ if (data.reachAvailable !== undefined) {
74
+ this.reachAvailable = data.reachAvailable;
75
+ }
25
76
  }
26
77
  }
27
78
 
@@ -131,7 +182,11 @@ export class SchedulingPostTimelineEntryDTO {
131
182
  * Returned by GET /social/analytics/content-decay.
132
183
  */
133
184
  export class SchedulingContentDecayDTO {
134
- window: '1h' | '6h' | '24h' | '7d';
185
+ /**
186
+ * Zernio now returns aggregate bucket labels (e.g. "0-6h", "6-12h"), not
187
+ * the old fixed windows — widened accordingly (AD2-1061, 2026-07-03).
188
+ */
189
+ window: string;
135
190
  platform: string;
136
191
  /** Percentage of peak engagement remaining at this window */
137
192
  pct: number;
@@ -0,0 +1,55 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { SchedulingInboxCommentDTO } from './I_SchedulingInbox';
4
+
5
+ // AD2-1088 — engagement + ownership pass-through on inbox comments, verbatim
6
+ // from Zernio GET /v1/inbox/comments/{postId} (OpenAPI v1.0.4:20799). Set
7
+ // only when Zernio provides a value; never zero-backfilled.
8
+
9
+ test('SchedulingInboxCommentDTO engagement/ownership fields are absent by default', () => {
10
+ const comment = new SchedulingInboxCommentDTO({
11
+ id: 'c1',
12
+ authorName: 'Jane',
13
+ text: 'hello',
14
+ createdAt: '2026-07-06T00:00:00.000Z',
15
+ });
16
+ assert.equal(comment.likeCount, undefined);
17
+ assert.equal(comment.replyCount, undefined);
18
+ assert.equal(comment.isOwner, undefined);
19
+ assert.equal(comment.parentId, undefined);
20
+ });
21
+
22
+ test('SchedulingInboxCommentDTO carries likeCount/replyCount/isOwner/parentId when provided', () => {
23
+ const comment = new SchedulingInboxCommentDTO({
24
+ id: 'c2',
25
+ authorName: 'Jane',
26
+ text: 'hello',
27
+ createdAt: '2026-07-06T00:00:00.000Z',
28
+ likeCount: 3,
29
+ replyCount: 1,
30
+ isOwner: true,
31
+ parentId: 'c1',
32
+ });
33
+ assert.equal(comment.likeCount, 3);
34
+ assert.equal(comment.replyCount, 1);
35
+ assert.equal(comment.isOwner, true);
36
+ assert.equal(comment.parentId, 'c1');
37
+ });
38
+
39
+ test('SchedulingInboxCommentDTO nests replies with the same optional fields', () => {
40
+ const reply = new SchedulingInboxCommentDTO({
41
+ id: 'c2',
42
+ authorName: 'Jane',
43
+ text: 'reply',
44
+ createdAt: '2026-07-06T00:00:00.000Z',
45
+ parentId: 'c1',
46
+ });
47
+ const comment = new SchedulingInboxCommentDTO({
48
+ id: 'c1',
49
+ authorName: 'Bob',
50
+ text: 'hello',
51
+ createdAt: '2026-07-06T00:00:00.000Z',
52
+ replies: [reply],
53
+ });
54
+ assert.equal(comment.replies?.[0].parentId, 'c1');
55
+ });
@@ -73,6 +73,14 @@ export class SchedulingInboxCommentDTO {
73
73
  authorAvatarUrl?: string;
74
74
  text: string;
75
75
  createdAt: string;
76
+ /** Likes on the comment, verbatim from Zernio (AD2-1088). Absent, never backfilled to 0. */
77
+ likeCount?: number;
78
+ /** Replies count on the comment, verbatim from Zernio (AD2-1088). */
79
+ replyCount?: number;
80
+ /** True when OUR connected account authored it — the answered state (AD2-1088). */
81
+ isOwner?: boolean;
82
+ /** Parent comment id on nested replies (AD2-1088). Absent on top-level comments. */
83
+ parentId?: string;
76
84
  replies?: SchedulingInboxCommentDTO[];
77
85
 
78
86
  constructor(data: SchedulingInboxCommentDTO) {
@@ -81,6 +89,10 @@ export class SchedulingInboxCommentDTO {
81
89
  this.authorAvatarUrl = data.authorAvatarUrl;
82
90
  this.text = data.text;
83
91
  this.createdAt = data.createdAt;
92
+ this.likeCount = data.likeCount;
93
+ this.replyCount = data.replyCount;
94
+ this.isOwner = data.isOwner;
95
+ this.parentId = data.parentId;
84
96
  this.replies = data.replies;
85
97
  }
86
98
  }
@@ -0,0 +1,92 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ SchedulingPostDTO,
5
+ SchedulingPostMediaItemDTO,
6
+ SchedulingPostTargetDTO,
7
+ SchedulingUpdatePostDTO,
8
+ SchedulingPlatformTargetDTO,
9
+ SchedulingMediaItemDTO,
10
+ SchedulingPostStatus,
11
+ } from './I_SchedulingPost';
12
+
13
+ // AD2-1083 / AD2-1026 / T022b — the compose edit-load fields the backend
14
+ // already types locally (SchedulingPostWithMediaDTO) as a clean intersection
15
+ // pending this lib bump. Pinning them here so ship-time is mechanical.
16
+
17
+ function basePost(overrides?: Partial<SchedulingPostDTO>): SchedulingPostDTO {
18
+ return new SchedulingPostDTO({
19
+ id: 'post-1',
20
+ platforms: ['instagram'],
21
+ platformStatuses: { instagram: 'scheduled' },
22
+ status: SchedulingPostStatus.SCHEDULED,
23
+ createdAt: '2026-07-06T00:00:00.000Z',
24
+ updatedAt: '2026-07-06T00:00:00.000Z',
25
+ ...overrides,
26
+ });
27
+ }
28
+
29
+ test('SchedulingPostDTO edit-load fields are absent by default (backward compatible)', () => {
30
+ const post = basePost();
31
+ assert.equal(post.thumbnailUrl, undefined);
32
+ assert.equal(post.mediaUrl, undefined);
33
+ assert.equal(post.content, undefined);
34
+ assert.equal(post.mediaItems, undefined);
35
+ assert.equal(post.platformTargets, undefined);
36
+ });
37
+
38
+ test('SchedulingPostDTO carries the media-miniature + edit-load fields when provided', () => {
39
+ const mediaItem = new SchedulingPostMediaItemDTO({
40
+ type: 'gif',
41
+ url: 'https://cdn.example.com/a.gif',
42
+ title: 'a',
43
+ });
44
+ const target = new SchedulingPostTargetDTO({
45
+ platform: 'instagram',
46
+ accountId: 'acc-1',
47
+ platformSpecificData: { shareToFeed: true },
48
+ });
49
+ const post = basePost({
50
+ thumbnailUrl: 'https://cdn.example.com/thumb.jpg',
51
+ mediaUrl: 'https://cdn.example.com/media.jpg',
52
+ content: 'the complete caption',
53
+ mediaItems: [mediaItem],
54
+ platformTargets: [target],
55
+ });
56
+ assert.equal(post.thumbnailUrl, 'https://cdn.example.com/thumb.jpg');
57
+ assert.equal(post.mediaUrl, 'https://cdn.example.com/media.jpg');
58
+ assert.equal(post.content, 'the complete caption');
59
+ assert.deepEqual(post.mediaItems, [mediaItem]);
60
+ assert.deepEqual(post.platformTargets, [target]);
61
+ });
62
+
63
+ test('SchedulingPostMediaItemDTO allows gif/document (response-only, wider than the create-input type)', () => {
64
+ const gif = new SchedulingPostMediaItemDTO({ type: 'gif' });
65
+ const doc = new SchedulingPostMediaItemDTO({ type: 'document' });
66
+ assert.equal(gif.type, 'gif');
67
+ assert.equal(doc.type, 'document');
68
+ });
69
+
70
+ test('SchedulingPostTargetDTO requires only platform, everything else optional', () => {
71
+ const target = new SchedulingPostTargetDTO({ platform: 'tiktok' });
72
+ assert.equal(target.platform, 'tiktok');
73
+ assert.equal(target.accountId, undefined);
74
+ assert.equal(target.platformSpecificData, undefined);
75
+ });
76
+
77
+ test('SchedulingUpdatePostDTO gains platforms/mediaItems for the edit-patch flow (AD2-1026)', () => {
78
+ const patch = new SchedulingUpdatePostDTO({
79
+ content: 'edited caption',
80
+ platforms: [new SchedulingPlatformTargetDTO({ platform: 'instagram', accountId: 'acc-1' })],
81
+ mediaItems: [new SchedulingMediaItemDTO({ type: 'image', url: 'https://cdn.example.com/x.jpg' })],
82
+ });
83
+ assert.equal(patch.content, 'edited caption');
84
+ assert.equal(patch.platforms?.[0].platform, 'instagram');
85
+ assert.equal(patch.mediaItems?.[0].type, 'image');
86
+ });
87
+
88
+ test('SchedulingUpdatePostDTO platforms/mediaItems are absent by default (backward compatible)', () => {
89
+ const patch = new SchedulingUpdatePostDTO({ content: 'x' });
90
+ assert.equal(patch.platforms, undefined);
91
+ assert.equal(patch.mediaItems, undefined);
92
+ });