ad2app-lib 1.11.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
+ }
@@ -16,10 +16,25 @@ test('ANALYTICS_SOURCES names the four analytics sources', () => {
16
16
  );
17
17
  });
18
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
+
19
32
  test('SchedulingAnalyticsKpiDTO carries failedSources when provided', () => {
20
33
  const kpi = new SchedulingAnalyticsKpiDTO({
21
34
  impressions: 10,
22
35
  reach: 5,
36
+ engagements: 4,
37
+ views: 20,
23
38
  engagementRate: 1.2,
24
39
  followerGrowth: 3,
25
40
  failedSources: ['follower-stats'],
@@ -31,9 +46,46 @@ test('failedSources is optional and absent by default (backward compatible)', ()
31
46
  const kpi = new SchedulingAnalyticsKpiDTO({
32
47
  impressions: 10,
33
48
  reach: 5,
49
+ engagements: 4,
50
+ views: 20,
34
51
  engagementRate: 1.2,
35
52
  followerGrowth: 3,
36
53
  });
37
54
  assert.equal(kpi.failedSources, undefined);
38
55
  assert.equal('failedSources' in JSON.parse(JSON.stringify(kpi)), false);
39
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
+ });
@@ -29,6 +29,10 @@ export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
29
29
  export class SchedulingAnalyticsKpiDTO {
30
30
  impressions: number;
31
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;
32
36
  engagementRate: number;
33
37
  followerGrowth: number;
34
38
  /**
@@ -38,15 +42,37 @@ export class SchedulingAnalyticsKpiDTO {
38
42
  * outage must never read as real zeros.
39
43
  */
40
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;
41
59
 
42
60
  constructor(data: SchedulingAnalyticsKpiDTO) {
43
61
  this.impressions = data.impressions;
44
62
  this.reach = data.reach;
63
+ this.engagements = data.engagements;
64
+ this.views = data.views;
45
65
  this.engagementRate = data.engagementRate;
46
66
  this.followerGrowth = data.followerGrowth;
47
67
  if (data.failedSources !== undefined) {
48
68
  this.failedSources = data.failedSources;
49
69
  }
70
+ if (data.impressionsAvailable !== undefined) {
71
+ this.impressionsAvailable = data.impressionsAvailable;
72
+ }
73
+ if (data.reachAvailable !== undefined) {
74
+ this.reachAvailable = data.reachAvailable;
75
+ }
50
76
  }
51
77
  }
52
78
 
@@ -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
+ });
@@ -78,11 +78,68 @@ export class SchedulingCreatePostDTO {
78
78
  export class SchedulingUpdatePostDTO {
79
79
  content?: string;
80
80
  scheduledAt?: string;
81
+ /**
82
+ * Per-platform targets for the edit patch (C2, AD2-1026) — the backend's
83
+ * `UpdatePostDTO` intersects this on top locally today; promoted here so
84
+ * the type ships from the lib instead of a local clone.
85
+ */
86
+ platforms?: SchedulingPlatformTargetDTO[];
87
+ /** Full media array for the edit patch (C2, AD2-1026), same shape as create. */
88
+ mediaItems?: SchedulingMediaItemDTO[];
81
89
 
82
90
  constructor(data?: Partial<SchedulingUpdatePostDTO>) {
83
91
  if (!data) return;
84
92
  this.content = data.content;
85
93
  this.scheduledAt = data.scheduledAt;
94
+ this.platforms = data.platforms;
95
+ this.mediaItems = data.mediaItems;
96
+ }
97
+ }
98
+
99
+ // ── SchedulingPostMediaItemDTO ────────────────────────────────────────────────
100
+
101
+ /**
102
+ * One media item on the edit-load response — verbatim from the live Zernio
103
+ * post's mediaItems (Post.mediaItems → MediaItem, OpenAPI v1.0.4:3223).
104
+ * Distinct from `SchedulingMediaItemDTO` (the create/update input shape):
105
+ * every field here is optional and `type` additionally allows `gif`/`document`
106
+ * to match what a fetched post can actually carry (T022b, AD2-1026).
107
+ */
108
+ export class SchedulingPostMediaItemDTO {
109
+ type?: 'image' | 'video' | 'gif' | 'document';
110
+ url?: string;
111
+ title?: string;
112
+
113
+ constructor(data?: Partial<SchedulingPostMediaItemDTO>) {
114
+ if (!data) return;
115
+ this.type = data.type;
116
+ this.url = data.url;
117
+ this.title = data.title;
118
+ }
119
+ }
120
+
121
+ // ── SchedulingPostTargetDTO ───────────────────────────────────────────────────
122
+
123
+ /**
124
+ * One per-platform target on the edit-load response — the request-shape
125
+ * subset of Zernio's PlatformTarget (OpenAPI v1.0.4:3275): `platform`,
126
+ * `accountId` (always normalized to the plain string id — Zernio expands it
127
+ * to an account object on fetched posts, yaml:3282), and
128
+ * `platformSpecificData` VERBATIM (yaml:3297, additionalProperties: true).
129
+ * Response-only fields (status, platformPostUrl, errors) are never exposed
130
+ * here — they already live on platformStatuses/postUrls/platformErrors
131
+ * (T022b, AD2-1026).
132
+ */
133
+ export class SchedulingPostTargetDTO {
134
+ platform: string;
135
+ accountId?: string;
136
+ platformSpecificData?: Record<string, unknown>;
137
+
138
+ constructor(data?: Partial<SchedulingPostTargetDTO>) {
139
+ if (!data) return;
140
+ this.platform = data.platform;
141
+ this.accountId = data.accountId;
142
+ this.platformSpecificData = data.platformSpecificData;
86
143
  }
87
144
  }
88
145
 
@@ -104,6 +161,25 @@ export class SchedulingPostDTO {
104
161
  contentSnippet?: string;
105
162
  createdAt: string;
106
163
  updatedAt: string;
164
+ /**
165
+ * Media-miniature pass-through fields (C1, AD2-1083) — present only when
166
+ * the cache row has a stored thumbnail/media URL, absent otherwise (never
167
+ * an empty string).
168
+ */
169
+ thumbnailUrl?: string;
170
+ mediaUrl?: string;
171
+ /**
172
+ * Edit-load fields for compose edit mode (C2, AD2-1026 + T022b):
173
+ * `content` is the COMPLETE caption (the cache only stores the 280-char
174
+ * `contentSnippet`); `mediaItems`/`platformTargets` are the full per-post
175
+ * media array and per-platform accountIds + compose settings. All three
176
+ * ride the live Zernio fetch: present when it succeeds (arrays may be
177
+ * possibly-empty = known-empty), ALL absent when it fails, which blocks
178
+ * edit frontend-side.
179
+ */
180
+ content?: string;
181
+ mediaItems?: SchedulingPostMediaItemDTO[];
182
+ platformTargets?: SchedulingPostTargetDTO[];
107
183
 
108
184
  constructor(data: SchedulingPostDTO) {
109
185
  this.id = data.id;
@@ -118,6 +194,11 @@ export class SchedulingPostDTO {
118
194
  this.contentSnippet = data.contentSnippet;
119
195
  this.createdAt = data.createdAt;
120
196
  this.updatedAt = data.updatedAt;
197
+ this.thumbnailUrl = data.thumbnailUrl;
198
+ this.mediaUrl = data.mediaUrl;
199
+ this.content = data.content;
200
+ this.mediaItems = data.mediaItems;
201
+ this.platformTargets = data.platformTargets;
121
202
  }
122
203
  }
123
204