diff --git a/packages/structures/__tests__/automoderation.test.ts b/packages/structures/__tests__/automoderation.test.ts new file mode 100644 index 000000000000..f38cebf41e0d --- /dev/null +++ b/packages/structures/__tests__/automoderation.test.ts @@ -0,0 +1,153 @@ +import { + type APIAutoModerationAction, + type APIAutoModerationActionMetadata, + type APIAutoModerationRule, + type APIAutoModerationRuleTriggerMetadata, + AutoModerationActionType, + AutoModerationRuleEventType, + AutoModerationRuleTriggerType, +} from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { + AutoModerationAction, + AutoModerationActionMetadata, + AutoModerationRule, + AutoModerationRuleTriggerMetadata, +} from '../src/automoderation/index.js'; +import { kPatch } from '../src/utils/symbols'; + +const ruleTriggerMetadataData: APIAutoModerationRuleTriggerMetadata = { + mention_total_limit: 5, +}; + +const actionMetadataData: APIAutoModerationActionMetadata = { + channel_id: '1', + custom_message: 'go away.', +}; + +const actions: APIAutoModerationAction[] = [ + { + type: AutoModerationActionType.BlockMessage, + metadata: actionMetadataData, + }, +]; + +const ruleData: APIAutoModerationRule = { + id: '1', + guild_id: '2', + name: 'ruleName', + creator_id: '1', + event_type: AutoModerationRuleEventType.MessageSend, + trigger_metadata: ruleTriggerMetadataData, + trigger_type: AutoModerationRuleTriggerType.MentionSpam, + enabled: true, + actions, + exempt_channels: ['1'], + exempt_roles: [], +}; + +describe('AutoModerationRule structure', () => { + const instance = new AutoModerationRule(ruleData); + const data = ruleData; + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.guildId).toBe(data.guild_id); + expect(instance.name).toBe(data.name); + expect(instance.creatorId).toBe(data.creator_id); + expect(instance.eventType).toBe(data.event_type); + expect(instance.enabled).toBe(data.enabled); + expect(instance.triggerType).toBe(data.trigger_type); + }); + + test('toJSON() correctly mirrors API data', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the AutoModerationRule works in place', () => { + const patched = instance[kPatch]({ + exempt_channels: ['2'], + exempt_roles: ['1', '2', '3'], + }); + + expect(patched.toJSON()).not.toEqual(data); + expect(instance.toJSON()).toEqual(patched.toJSON()); + }); + + describe('AutoModerationRuleTriggerMetadata sub-structure', () => { + const instance = new AutoModerationRuleTriggerMetadata(ruleTriggerMetadataData); + const data = ruleTriggerMetadataData; + + test('getters return correct values', () => { + expect(instance.allowList).toBe(data.allow_list); + expect(instance.keywordFilter).toBe(data.keyword_filter); + expect(instance.mentionRaidProtectionEnabled).toBe(data.mention_raid_protection_enabled); + expect(instance.mentionTotalLimit).toBe(data.mention_total_limit); + expect(instance.presets).toBe(data.presets); + expect(instance.regexPatterns).toBe(data.regex_patterns); + }); + + test('toJSON() returns expected API data', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in place', () => { + const patched = instance[kPatch]({ + mention_total_limit: 10, + }); + + expect(patched.mentionTotalLimit).toBe(patched.mentionTotalLimit); + + expect(patched.toJSON()).toEqual(instance.toJSON()); + }); + }); + + describe('AutoModerationAction structure', () => { + const instance = new AutoModerationAction(actions[0] as APIAutoModerationAction); + const data = actions[0]; + + test('correct value for all getters', () => { + expect(instance.type).toBe(data!.type); + }); + + test('toJSON() returns expected API data', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in place', () => { + const patched = instance[kPatch]({ + type: AutoModerationActionType.Timeout, + }); + + expect(instance.type).toBe(patched.type); + + expect(patched.toJSON()).toEqual(instance.toJSON()); + }); + }); + + describe('AutoModerationActionMetadata sub-structure', () => { + const instance = new AutoModerationActionMetadata(actionMetadataData); + const data = actionMetadataData; + + test('all getters working as expected', () => { + expect(instance.channelId).toBe(data.channel_id); + expect(instance.customMessage).toBe(data.custom_message); + expect(instance.durationSeconds).toBe(data.duration_seconds); + }); + + test('toJSON() returns expected results', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in place', () => { + const patched = instance[kPatch]({ + custom_message: 'noo come back', + duration_seconds: 100, + }); + + expect(patched.toJSON()).toStrictEqual(instance.toJSON()); + expect(patched.customMessage).toBe(instance.customMessage); + expect(patched.durationSeconds).toBe(instance.durationSeconds); + }); + }); +}); diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts index f81474c6520c..41e71e522c6a 100644 --- a/packages/structures/__tests__/channels.test.ts +++ b/packages/structures/__tests__/channels.test.ts @@ -1,3 +1,4 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; import type { APIAnnouncementThreadChannel, APIDMChannel, @@ -85,6 +86,8 @@ describe('text channel', () => { expect(instance.topic).toBe(data.topic); expect(instance.type).toBe(ChannelType.GuildText); expect(instance.url).toBe('https://discord.com/channels/2/1'); + expect(instance.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(instance.id!)); + expect(instance.createdAt).toEqual(new Date(instance.createdTimestamp!)); expect(instance.toJSON()).toEqual(data); }); diff --git a/packages/structures/__tests__/emoji.test.ts b/packages/structures/__tests__/emoji.test.ts new file mode 100644 index 000000000000..e212e511cc9a --- /dev/null +++ b/packages/structures/__tests__/emoji.test.ts @@ -0,0 +1,55 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import type { APIEmoji, APIUser } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { Emoji } from '../src/emojis/Emoji.js'; +import { kPatch } from '../src/utils/symbols.js'; + +describe('Emoji structure', () => { + const user: APIUser = { + id: '1', + username: 'username', + discriminator: '0000', + global_name: 'djs.structures.user.global_name', + avatar: 'djs.structures.user.avatar', + }; + + const data: APIEmoji = { + id: '1', + name: 'name', + roles: ['1', '2', '3'], + user, + require_colons: true, + managed: true, + animated: true, + available: true, + }; + + const instance = new Emoji(data); + + test('Emoji has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.requireColons).toBe(data.require_colons); + expect(instance.managed).toBe(data.managed); + expect(instance.animated).toBe(data.animated); + expect(instance.available).toBe(data.available); + + expect(instance.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(instance.id!)); + expect(instance.createdAt).toEqual(new Date(instance.createdTimestamp!)); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the Emoji works in place', () => { + const patched = instance[kPatch]({ + available: false, + }); + + expect(patched.available).toEqual(false); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/entitlement.test.ts b/packages/structures/__tests__/entitlement.test.ts new file mode 100644 index 000000000000..de22ec9ddce5 --- /dev/null +++ b/packages/structures/__tests__/entitlement.test.ts @@ -0,0 +1,62 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { type APIEntitlement, EntitlementType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { Entitlement } from '../src/entitlements/Entitlement.js'; +import { kPatch } from '../src/utils/symbols.js'; + +describe('Entitlement structure', () => { + const data: APIEntitlement = { + id: '1', + sku_id: '1', + application_id: '1', + user_id: '1', + type: EntitlementType.Purchase, + deleted: false, + starts_at: '2020-10-10T13:50:17.209000+00:00', + ends_at: '2020-10-10T15:50:17.209000+00:00', + consumed: false, + // note guild_id is missing (to test kPatch) + }; + + const instance = new Entitlement(data); + + test('Entitlement has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.skuId).toBe(data.sku_id); + expect(instance.applicationId).toBe(data.application_id); + expect(instance.userId).toBe(data.user_id); + expect(instance.type).toBe(data.type); + expect(instance.consumed).toBe(data.consumed); + expect(instance.deleted).toBe(data.deleted); + expect(instance.guildId).toBeUndefined(); + + expect(instance.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(instance.id!)); + expect(instance.createdAt).toEqual(new Date(instance.createdTimestamp!)); + + expect(instance.startsTimestamp).toBe(new Date(data.starts_at!).getTime()); + expect(instance.startsAt).toEqual(new Date(instance.startsTimestamp!)); + + expect(instance.endsTimestamp).toBe(new Date(data.ends_at!).getTime()); + expect(instance.endsAt).toEqual(new Date(instance.endsTimestamp!)); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the Entitlement works in place', () => { + const guildId = '111111'; + const consumed = true; + + const patched = instance[kPatch]({ + guild_id: guildId, + consumed, + }); + + expect(patched.guildId).toEqual(guildId); + expect(patched.consumed).toEqual(consumed); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/message.components.test.ts b/packages/structures/__tests__/message.components.test.ts new file mode 100644 index 000000000000..8ffd3f2218d5 --- /dev/null +++ b/packages/structures/__tests__/message.components.test.ts @@ -0,0 +1,1004 @@ +import { + type APIComponentInMessageActionRow, + type APIActionRowComponent, + type APIChannelSelectComponent, + type APIMessageComponentEmoji, + type APIContainerComponent, + type APIFileComponent, + type APIFileUploadComponent, + type APIButtonComponentWithCustomId, + type APILabelComponent, + type APIButtonComponentWithURL, + type APIMediaGalleryComponent, + type APIMediaGalleryItem, + type APIMentionableSelectComponent, + type APIButtonComponentWithSKUId, + type APIRoleSelectComponent, + type APISectionComponent, + type APISelectMenuDefaultValue, + type APISeparatorComponent, + type APIStringSelectComponent, + type APISelectMenuOption, + type APITextDisplayComponent, + type APITextInputComponent, + type APIThumbnailComponent, + type APIUnfurledMediaItem, + type APIUserSelectComponent, + ButtonStyle, + ComponentType, + ChannelType, + SelectMenuDefaultValueType, + SeparatorSpacingSize, + TextInputStyle, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { + ActionRowComponent, + ChannelSelectMenuComponent, + ComponentEmoji, + ContainerComponent, + FileComponent, + FileUploadComponent, + InteractiveButtonComponent, + LabelComponent, + LinkButtonComponent, + MediaGalleryComponent, + MediaGalleryItem, + MentionableSelectMenuComponent, + PremiumButtonComponent, + RoleSelectMenuComponent, + SelectMenuDefaultValue, + SeparatorComponent, + StringSelectMenuComponent, + StringSelectMenuOption, + TextDisplayComponent, + TextInputComponent, + ThumbnailComponent, + UnfurledMediaItem, + UserSelectMenuComponent, + SectionComponent, +} from '../src/index.js'; +import { kPatch } from '../src/utils/symbols'; + +const textDisplayComponent: APITextDisplayComponent = { + content: 'djs://text-display-component-content', + type: ComponentType.TextDisplay, +}; + +const fileUploadComponent: APIFileUploadComponent = { + custom_id: 'djs://file-upload-component-custom-id', + id: 100, + max_values: 1_000_000, + type: ComponentType.FileUpload, + required: true, +}; + +const labelComponent: APILabelComponent = { + label: 'djs://label-button-component-label', + type: ComponentType.Label, + component: fileUploadComponent, +}; + +const buttonComponentWithURL: APIButtonComponentWithURL = { + id: 1, + url: 'button-component-with-url', + style: ButtonStyle.Link, + type: ComponentType.Button, +}; + +const actionRowComponent: APIActionRowComponent = { + type: ComponentType.ActionRow, + components: [buttonComponentWithURL], +}; + +const channelSelectComponent: APIChannelSelectComponent = { + custom_id: 'djs://channel-select-component-custom-id', + type: ComponentType.ChannelSelect, + channel_types: [ChannelType.GuildForum, ChannelType.DM], + disabled: false, + default_values: [], + max_values: 1_000, + placeholder: 'djs://channel-select-component-pceholder', + required: true, +}; + +/* todo: could this be a partial Emoji instead? */ +const emojiComponent: APIMessageComponentEmoji = { + id: '1', + name: 'djs://emoji-name', +}; + +const containerComponent: APIContainerComponent = { + id: 212, + accent_color: 0x676767, + components: [], + type: ComponentType.Container, +}; + +const unfurledMediaItem: APIUnfurledMediaItem = { + url: 'djs://unfurled-media-item-url', + height: 1, + width: 2, + placeholder: 'djs://unfurled-media-item-placeholder', +}; + +const fileComponent: APIFileComponent = { + id: 111, + name: 'file.exe', + size: 10_000, + file: unfurledMediaItem, + type: ComponentType.File, +}; + +const buttonComponentWithCustomId: APIButtonComponentWithCustomId = { + custom_id: 'djs://button-component-with-sku-id', + label: 'djs://button-component-with-custom-id-label', + id: 10, + style: ButtonStyle.Primary, + type: ComponentType.Button, +}; + +const mediaGalleryItem: APIMediaGalleryItem = { + description: 'djs://media-gallery-item-descriptor', + media: unfurledMediaItem, +}; + +const mediaGalleryComponent: APIMediaGalleryComponent = { + items: [], + type: ComponentType.MediaGallery, +}; + +const mentionableSelectComponent: APIMentionableSelectComponent = { + custom_id: 'djs://mentionable-select-component-custom-id', + id: 10, + disabled: false, + required: true, + max_values: 10_000, + placeholder: 'djs://mention-select-component-placeholder', + type: ComponentType.MentionableSelect, +}; + +const buttonComponentWithSKUId: APIButtonComponentWithSKUId = { + disabled: false, + id: 12, + sku_id: 'djs://button-component-with-sku-id', + style: ButtonStyle.Premium, + type: ComponentType.Button, +}; + +const roleSelectComponent: APIRoleSelectComponent = { + custom_id: '', + disabled: true, + id: 123, + max_values: 10_000, + placeholder: 'djs://role-select-component-placeholder', + required: false, + type: ComponentType.RoleSelect, +}; + +const sectionComponent: APISectionComponent = { + components: [textDisplayComponent], + accessory: buttonComponentWithCustomId, + id: 4_123, + type: ComponentType.Section, +}; + +const selectMenuDefaultValue: APISelectMenuDefaultValue = { + id: '1', + /* todo as the above id is a snowflake, are we missing created[Date/Timestamp] on the getters? */ + type: SelectMenuDefaultValueType.User, +}; + +const separatorComponent: APISeparatorComponent = { + divider: true, + spacing: SeparatorSpacingSize.Small, + id: 100, + type: ComponentType.Separator, +}; + +const stringSelectComponent: APIStringSelectComponent = { + id: 23, + options: [], + custom_id: 'djs://string-select-component-custom-id', + disabled: false, + type: ComponentType.StringSelect, + max_values: 10_000, + placeholder: 'djs://string-select-menu-component-placeholder', + required: true, +}; + +const selectMenuOption: APISelectMenuOption = { + default: true, + label: 'djs://select-menu-option-label', + value: 'djs://select-menu-option-value', +}; + +const textInputComponent: APITextInputComponent = { + id: 11, + style: TextInputStyle.Short, + custom_id: 'djs://text-input-component-custom-id', + type: ComponentType.TextInput, + min_length: 4_000, + label: 'djs://text-input-component-label', + required: true, + value: 'djs://text-input-component-value', +}; + +const thumbnailComponent: APIThumbnailComponent = { + id: 10, + media: unfurledMediaItem, + type: ComponentType.Thumbnail, + spoiler: true, + description: 'djs://thumbnail-component-description', +}; + +const userSelectComponent: APIUserSelectComponent = { + id: 1, + disabled: false, + max_values: 1_000, + min_values: 10, + custom_id: 'djs://user-select-component-custom-id', + type: ComponentType.UserSelect, + required: true, + placeholder: 'djs://user-select-component-placeholder', +}; + +describe('Message components structures', () => { + describe('UserSelectComponet Structure', () => { + const data = userSelectComponent; + const instance = new UserSelectMenuComponent(data); + + test('UserSelectComponet has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.id).toBe(data.id); + expect(instance.disabled).toBe(data.disabled); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.minValues).toBe(data.min_values); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.required).toBe(data.required); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the UserSelectComponet works in place', () => { + const patched = instance[kPatch]({ + required: false, + placeholder: 'djs://[PATCHED]-user-select-component-placeholder', + }); + + expect(patched.required).toEqual(false); + expect(patched.placeholder).toEqual('djs://[PATCHED]-user-select-component-placeholder'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ThumbnailComponent Structure', () => { + const data = thumbnailComponent; + const instance = new ThumbnailComponent(data); + + test('ThumbnailComponent has all properties', () => { + expect(instance.description).toBe(data.description); + expect(instance.id).toBe(data.id); + expect(instance.spoiler).toBe(data.spoiler); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the ThumbnailCompoonent works in place', () => { + const patched = instance[kPatch]({ + spoiler: false, + description: 'djs://[PATCHED]-thumbnail-component-description', + }); + + expect(patched.spoiler).toEqual(false); + expect(patched.description).toEqual('djs://[PATCHED]-thumbnail-component-description'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('TextInputComponent Structure', () => { + const data = textInputComponent; + const instance = new TextInputComponent(data); + + test('TextInputComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.id).toBe(data.id); + expect(instance.label).toBe(data.label); + expect(instance.minLength).toBe(data.min_length); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.required).toBe(data.required); + expect(instance.style).toBe(data.style); + expect(instance.type).toBe(data.type); + expect(instance.value).toBe(data.value); + + expect(instance.maxLength).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the TextInputComponent works in place', () => { + const patched = instance[kPatch]({ + label: 'djs://[PATCHED]-text-input-component-label', + placeholder: 'djs://[PATCHED]-text-input-component-placeholder', + max_length: 10_000, + }); + + expect(patched.label).toEqual('djs://[PATCHED]-text-input-component-label'); + expect(patched.placeholder).toEqual('djs://[PATCHED]-text-input-component-placeholder'); + expect(patched.maxLength).toBe(10_000); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('TextDisplayComponent Structure', () => { + const data = textDisplayComponent; + const instance = new TextDisplayComponent(data); + + test('TextDisplayComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.content).toBe(data.content); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the TextDisplayComponent works in place', () => { + const patched = instance[kPatch]({ + content: 'djs://[PATCHED]-text-display-component-new-context', + }); + + expect(patched.content).toEqual('djs://[PATCHED]-text-display-component-new-context'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('StringSelectMenuOption Structure (dapi-types reference: APISelectMenuOption)', () => { + const data = selectMenuOption; + const instance = new StringSelectMenuOption(data); + + test('StringSelectMenuOption has all properties', () => { + expect(instance.default).toBe(data.default); + expect(instance.label).toBe(data.label); + expect(instance.value).toBe(data.value); + + expect(instance.description).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the StringSelectMenuOption works in place', () => { + const patched = instance[kPatch]({ + description: 'djs://[PATCHED]-string-select-menu-option-new-description', + }); + + expect(patched.description).toEqual('djs://[PATCHED]-string-select-menu-option-new-description'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('StringSelectMenuComponent Structure (dapi-types reference: APIStringSelectComponent)', () => { + const data = stringSelectComponent; + const instance = new StringSelectMenuComponent(data); + + test('StringSelectMenuComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.disabled).toBe(data.disabled); + expect(instance.id).toBe(data.id); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.required).toBe(data.required); + expect(instance.type).toBe(data.type); + + expect(instance.minValues).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the StringSelectMenuComponent works in place', () => { + const patched = instance[kPatch]({ + min_values: 10_000, + }); + + expect(patched.minValues).toEqual(10_000); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('SeparatorComponent Structure', () => { + const data = separatorComponent; + const instance = new SeparatorComponent(data); + + test('SeparatorComponent has all properties', () => { + expect(instance.divider).toBe(data.divider); + expect(instance.id).toBe(data.id); + expect(instance.spacing).toBe(data.spacing); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the SeparatorComponent works in place', () => { + const patched = instance[kPatch]({ + divider: false, + spacing: SeparatorSpacingSize.Large, + }); + + expect(patched.divider).toEqual(false); + expect(patched.spacing).toEqual(SeparatorSpacingSize.Large); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('SelectMenuDefaultValue Structure', () => { + const data = selectMenuDefaultValue; + const instance = new SelectMenuDefaultValue(data); + + test('SelectMenuDefaultValue has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.type).toBe(data.type); + /* Todo: as instance.id is a snowflake and there are no created[Date/Timestamp] getters exposed + on the clss, is this something that we are missing? + */ + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the SelectMenuDefaultValue works in place', () => { + const patched = instance[kPatch]({ + type: SelectMenuDefaultValueType.Channel, + }); + + expect(patched.type).toEqual(SelectMenuDefaultValueType.Channel); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('SectionComponent Structure', () => { + const data = sectionComponent; + const instance = new SectionComponent(data); + + test('SectionComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the SectionComponent works in place', () => { + const patched = instance[kPatch]({ + id: 10_001, + }); + + expect(patched.id).toBe(10_001); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('RoleSelectMenuComponent Structure (dapi-types reference: APIRoleSelectComponent)', () => { + const data = roleSelectComponent; + const instance = new RoleSelectMenuComponent(data); + + test('RoleSelectMenuComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.disabled).toBe(data.disabled); + expect(instance.id).toBe(data.id); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.required).toBe(data.required); + expect(instance.type).toBe(data.type); + + expect(instance.minValues).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the RoleSelectMenuComponent works in place', () => { + const patched = instance[kPatch]({ + min_values: 100, + placeholder: 'djs://[PATCHED]-role-select-menu-component-new-placeholder', + }); + + expect(patched.minValues).toEqual(100); + expect(patched.placeholder).toEqual('djs://[PATCHED]-role-select-menu-component-new-placeholder'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('PremiumButtonComponent Structure (dapi-types reference: APIButtonComponentWithSKUId)', () => { + const data = buttonComponentWithSKUId; + const instance = new PremiumButtonComponent(data); + + test('PremiumButtonComponent has all properties', () => { + expect(instance.disabled).toBe(data.disabled); + expect(instance.id).toBe(data.id); + expect(instance.skuId).toBe(data.sku_id); + expect(instance.style).toBe(data.style); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the PremiumButtonComponent works in place', () => { + const patched = instance[kPatch]({ + disabled: true, + id: 100, + }); + + expect(patched.disabled).toEqual(true); + expect(patched.id).toEqual(100); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MentionableSelectMenuComponent Structure (dapi-types reference: APIMentionableSelectComponent)', () => { + const data = mentionableSelectComponent; + const instance = new MentionableSelectMenuComponent(data); + + test('MentionableSelectMenuComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.disabled).toBe(data.disabled); + expect(instance.id).toBe(data.id); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.required).toBe(data.required); + expect(instance.type).toBe(data.type); + + expect(instance.minValues).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the MentionableSelectMenuComponent works in place', () => { + const patched = instance[kPatch]({ + min_values: 100, + required: false, + }); + + expect(patched.minValues).toEqual(100); + expect(patched.required).toEqual(false); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MediaGalleryComponent Structure', () => { + const data = mediaGalleryComponent; + const instance = new MediaGalleryComponent(data); + + test('MediaGalleryComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the MediaGalleryComponent works in place', () => { + const patched = instance[kPatch]({ + id: 1_000_000, + }); + + expect(patched.id).toEqual(1_000_000); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MediaGalleryItem Structure', () => { + const data = mediaGalleryItem; + const instance = new MediaGalleryItem(data); + + test('MediaGalleryItem has all properties', () => { + expect(instance.description).toBe(data.description); + + expect(instance.spoiler).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the MediaGalleryItem works in place', () => { + const patched = instance[kPatch]({ + description: 'djs://[PATCHED]-media-gallery-item-new-descriptor', + spoiler: true, + }); + + expect(patched.description).toEqual('djs://[PATCHED]-media-gallery-item-new-descriptor'); + expect(patched.spoiler).toEqual(true); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('LinkButtonComponent Structure (dapi-tyes reference: ButtonComponentWithURL)', () => { + const data = buttonComponentWithURL; + const instance = new LinkButtonComponent(data); + + test('LinkButtonComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.label).toBe(data.label); + expect(instance.style).toBe(data.style); + expect(instance.type).toBe(data.type); + expect(instance.url).toBe(data.url); + + expect(instance.disabled).toBeNull(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the LinkButtonComponent works in place', () => { + const patched = instance[kPatch]({ + disabled: true, + url: '[PATCHED]-link-button-component-new-url', + }); + + expect(patched.disabled).toEqual(true); + expect(patched.url).toEqual('[PATCHED]-link-button-component-new-url'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('InteractiveButtonComponent Structure (dapi-types reference: APIButtonComponentWithCustomId)', () => { + const data = buttonComponentWithCustomId; + const instance = new InteractiveButtonComponent(data); + + test('InteractiveButtonComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.id).toBe(data.id); + expect(instance.label).toBe(data.label); + expect(instance.style).toBe(data.style); + expect(instance.type).toBe(data.type); + + expect(instance.disabled).toBeNull(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the InteractiveButtonComponent works in place', () => { + const patched = instance[kPatch]({ + disabled: true, + custom_id: 'djs://[PATCHED]-button-component-with-new-custom-id', + }); + + expect(patched.disabled).toEqual(true); + expect(patched.customId).toEqual('djs://[PATCHED]-button-component-with-new-custom-id'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('FileComponent Structure', () => { + const data = fileComponent; + const instance = new FileComponent(data); + + test('FileComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.size).toBe(data.size); + expect(instance.type).toBe(data.type); + + expect(instance.spoiler).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the FileComponent works in place', () => { + const patched = instance[kPatch]({ + spoiler: true, + size: 1, + }); + + expect(patched.spoiler).toEqual(true); + expect(patched.size).toEqual(1); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('fileUploadComponent Structure', () => { + const data = fileUploadComponent; + const instance = new FileUploadComponent(data); + + test('fileUploadComponent has all properties', () => { + expect(instance.customId).toBe(data.custom_id); + expect(instance.id).toBe(data.id); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.required).toBe(data.required); + expect(instance.type).toBe(data.type); + + expect(instance.minValues).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the fileUploadComponent works in place', () => { + const patched = instance[kPatch]({ + min_values: 10_000, + required: false, + }); + + expect(patched.minValues).toEqual(10_000); + expect(patched.required).toEqual(false); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('UnfurledMediaItem Structure', () => { + const data = unfurledMediaItem; + const instance = new UnfurledMediaItem(data); + + test('UnfurledMediaItem has all properties', () => { + expect(instance.attachmentId).toBe(data.attachment_id); + expect(instance.contentType).toBe(data.content_type); + expect(instance.height).toBe(data.height); + expect(instance.width).toBe(data.width); + expect(instance.url).toBe(data.url); + + expect(instance.proxyURL).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the UnfurledMediaItem works in place', () => { + const patched = instance[kPatch]({ + proxy_url: 'djs://proxy-url', + width: 222, + }); + + expect(patched.proxyURL).toEqual('djs://proxy-url'); + expect(patched.width).toEqual(222); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ContainerComponent Structure', () => { + const data = containerComponent; + const instance = new ContainerComponent(data); + + test('correct value for all getters and helper method [hexAccentColor]', () => { + expect(instance.accentColor).toBe(data.accent_color); + expect(instance.id).toBe(data.id); + expect(instance.type).toBe(data.type); + + expect(instance.hexAccentColor).toEqual(`#${data.accent_color!.toString(16)}`); + + expect(instance.spoiler).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the ContainerComponent works in place', () => { + const patched = instance[kPatch]({ + spoiler: true, + id: 2_000_000, + }); + + expect(patched.spoiler).toEqual(true); + expect(patched.id).toEqual(2_000_000); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ComponentEmoji Structure', () => { + const data = emojiComponent; + const instance = new ComponentEmoji(data); + + test('ComponentEmoji has all properties', () => { + expect(instance.name).toBe(data.name); + expect(instance.id).toBe(data.id); + + expect(instance.animated).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the ComponentEmoji works in place', () => { + const patched = instance[kPatch]({ + animated: true, + id: '1234', + }); + + expect(patched.animated).toEqual(true); + expect(patched.id).toEqual('1234'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ChannelSelectMenuComponent Structure', () => { + const data = channelSelectComponent; + const instance = new ChannelSelectMenuComponent(data); + + test('ChannelSelectMenuComponent has all properties', () => { + expect(instance.channelTypes).toBe(data.channel_types); + expect(instance.customId).toBe(data.custom_id); + expect(instance.disabled).toBe(data.disabled); + expect(instance.id).toBe(data.id); + expect(instance.maxValues).toBe(data.max_values); + expect(instance.placeholder).toBe(data.placeholder); + expect(instance.type).toBe(data.type); + + expect(instance.minValues).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the ChannelSelectMenuComponent works in place', () => { + const patched = instance[kPatch]({ + min_values: 10, + disabled: true, + }); + + expect(patched.minValues).toEqual(10); + expect(patched.disabled).toEqual(true); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ActionRowComponent Structure', () => { + const data = actionRowComponent; + const instance = new ActionRowComponent(data); + + test('ActionRowComponent has all properties', () => { + expect(instance.type).toBe(data.type); + + expect(instance.id).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the ActionRowComponent works in place', () => { + const patched = instance[kPatch]({ + id: 1_234, + }); + + expect(patched.id).toEqual(1_234); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('TextDisplayComponent Structure', () => { + const data = textDisplayComponent; + const instance = new TextDisplayComponent(data); + + test('TextDisplayComponent has all properties', () => { + expect(instance.content).toBe(data.content); + expect(instance.type).toBe(data.type); + + expect(instance.id).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the TextDisplayComponent works in place', () => { + const patched = instance[kPatch]({ + id: 4_123, + content: 'djs://[PATCHED]-text-display-component-new-content', + }); + + expect(patched.content).toEqual('djs://[PATCHED]-text-display-component-new-content'); + expect(patched.id).toEqual(4_123); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('LabelComponent Structure', () => { + const data = labelComponent; + const instance = new LabelComponent(data); + + test('LabelComponent has all properties', () => { + expect(instance.id).toBe(data.id); + expect(instance.label).toBe(data.label); + expect(instance.type).toBe(data.type); + + expect(instance.description).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the LabelComponent works in place', () => { + const patched = instance[kPatch]({ + description: 'djs://[PATCHED]-label-component-description', + label: 'djs://[PATCHED]-label-component-new-label', + }); + + expect(patched.description).toEqual('djs://[PATCHED]-label-component-description'); + expect(patched.label).toEqual('djs://[PATCHED]-label-component-new-label'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); +}); diff --git a/packages/structures/__tests__/message.embeds.test.ts b/packages/structures/__tests__/message.embeds.test.ts new file mode 100644 index 000000000000..a7c42eb18c74 --- /dev/null +++ b/packages/structures/__tests__/message.embeds.test.ts @@ -0,0 +1,344 @@ +import { + EmbedType, + type APIEmbed, + type APIEmbedAuthor, + type APIEmbedField, + type APIEmbedFooter, + type APIEmbedImage, + type APIEmbedProvider, + type APIEmbedThumbnail, + type APIEmbedVideo, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { + Embed, + EmbedVideo, + EmbedThumbnail, + EmbedProvider, + EmbedImage, + EmbedFooter, + EmbedField, + EmbedAuthor, +} from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +/** + * for kPatch, set url and add iconURL + */ +const author: APIEmbedAuthor = { + name: 'djs://embed-author-data-name', + url: 'djs://embed-author-data-url', +}; + +/** + * for kPatch, set inline and update value + */ +const fields: APIEmbedField[] = [ + { + name: 'djs://embed-field-data-name', + value: 'djs://embed-field-data-value', + }, +]; + +/** + * for kPatch, update iconURL and set 'proxy' + */ +const footer: APIEmbedFooter = { + text: 'djs://embed-footer-data-text', + icon_url: 'embed-footer-data-icon-url', +}; + +/** + * for kPatch, add proxyURL and height/width + */ +const image: APIEmbedImage = { + url: 'djs://embed-image-data-url', + height: 1, + width: 2, +}; + +/** + * for kPatch, update name and remove url + */ +const provider: APIEmbedProvider = { + name: 'djs://embed-provider-data-name', + url: 'embed-provider-data-url', +}; + +/** + * add proxyURL and update h/w + */ +const thumbnail: APIEmbedThumbnail = { + url: 'djs://embed-thumbnail-data-url', + height: 1, + width: 2, +}; + +/** + * for kPatch, set proxy and update h/w + */ +const video: APIEmbedVideo = { + url: 'djs://embed-video-data-url', + height: 1, + width: 2, +}; + +/** + * for kPatch, add fields + */ +const data: APIEmbed[] = [ + { + title: 'djs://embed-title', + type: EmbedType.Rich, + description: 'djs://embed-description', + color: 0x676767, + timestamp: '2020-10-10T13:50:17.209000+00:00', + footer, + image, + thumbnail, + video, + provider, + author, + fields, + }, +]; + +const embeds = data.map((x) => new Embed(x)); + +describe('embed structure', () => { + const embedData = data[0]!; + const instance = embeds[0]!; + + test('correct values for all getters and helper method [hexColor]', () => { + expect(instance.color).toBe(embedData.color); + expect(instance.description).toBe(embedData.description); + expect(instance.type).toBe(embedData.type); + + /* * @todo - there is no timestampDate getter on Embed. Is this intentional? */ + expect(instance.timestamp).toBe(new Date(embedData.timestamp!).getTime()); + expect(instance.hexColor).toEqual(`#${embedData.color!.toString(16)}`); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(embedData); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + title: 'djs://[PATCHED]-embed-title', + url: '[PATCHED]-embed-url', + }); + + expect(patched.title).toBe('djs://[PATCHED]-embed-title'); + expect(patched.url).toBe('[PATCHED]-embed-url'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + + describe('embed video sub-structure', () => { + const data = video; + const instance = new EmbedVideo(video); + + test('correct values for all getters', () => { + expect(instance.url).toBe(data.url); + expect(instance.height).toBe(data.height); + expect(instance.width).toBe(data.width); + + expect(instance.proxyURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + proxy_url: 'djs://[PATCHED]-embed-video-proxy-url', + height: 2, + width: 22, + }); + + expect(patched.proxyURL).toEqual('djs://[PATCHED]-embed-video-proxy-url'); + expect(patched.height).toEqual(2); + expect(patched.width).toEqual(22); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed thumbnail sub-structure', () => { + const data = thumbnail; + const instance = new EmbedThumbnail(thumbnail); + + test('correct values for all getters', () => { + expect(instance.url).toBe(data.url); + expect(instance.height).toBe(data.height); + expect(instance.width).toBe(data.width); + + expect(instance.proxyURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + proxy_url: 'djs://[PATCHED]-embed-thumbnail-proxy-url', + height: 22, + width: 33, + }); + + expect(patched.proxyURL).toEqual('djs://[PATCHED]-embed-thumbnail-proxy-url'); + expect(patched.height).toEqual(22); + expect(patched.width).toEqual(33); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed provider sub-structure', () => { + const data = provider; + const instance = new EmbedProvider(data); + + test('correct values for all getters', () => { + expect(instance.name).toBe(data.name); + expect(instance.url).toBe(data.url); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + name: 'djs://[PATCHED]-embed-provider-name', + }); + + expect(patched.name).toEqual('djs://[PATCHED]-embed-provider-name'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed image sub-structure', () => { + const data = image; + const instance = new EmbedImage(image); + + test('correct values for all getters', () => { + expect(instance.url).toBe(data.url); + expect(instance.height).toBe(data.height); + expect(instance.width).toBe(data.width); + + expect(instance.proxyURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + proxy_url: 'djs://[PATCHED]-embed-thumbnail-proxy-url', + height: 22, + width: 33, + }); + + expect(patched.proxyURL).toEqual('djs://[PATCHED]-embed-thumbnail-proxy-url'); + expect(patched.height).toEqual(22); + expect(patched.width).toEqual(33); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed footer sub-structure', () => { + const data = footer; + const instance = new EmbedFooter(footer); + + test('correct values for all getters', () => { + expect(instance.text).toBe(data.text); + expect(instance.iconURL).toBe(data.icon_url); + + expect(instance.proxyIconURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + icon_url: 'djs://[PATCHED]-embed-footer-icon-url', + }); + + expect(patched.iconURL).toEqual('djs://[PATCHED]-embed-footer-icon-url'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed field sub-structure', () => { + const data = fields[0]!; + const instance = new EmbedField(data)!; + + test('correct values for all getters', () => { + expect(instance.name).toBe(data.name); + expect(instance.value).toBe(data.value); + + expect(instance.inline).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + value: 'djs://[PATCHED]-embed-field-value', + inline: true, + }); + + expect(patched.value).toEqual('djs://[PATCHED]-embed-field-value'); + expect(patched.inline).toEqual(true); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('embed author sub-structure', () => { + const data = author; + const instance = new EmbedAuthor(data); + + test('correct values for all getters', () => { + expect(instance.name).toBe(data.name); + expect(instance.url).toBe(data.url); + + expect(instance.iconURL).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + name: 'djs://[PATCHED]-embed-author-name', + proxy_icon_url: '[PATCHED]-embed-author-proxy-icon-url', + }); + + expect(patched.name).toBe('djs://[PATCHED]-embed-author-name'); + expect(patched.proxyIconURL).toEqual('[PATCHED]-embed-author-proxy-icon-url'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); +}); diff --git a/packages/structures/__tests__/message.test.ts b/packages/structures/__tests__/message.test.ts index 5c739f7bd785..b91fa99fe0f0 100644 --- a/packages/structures/__tests__/message.test.ts +++ b/packages/structures/__tests__/message.test.ts @@ -1,13 +1,25 @@ import { DiscordSnowflake } from '@sapphire/snowflake'; import type { APIActionRowComponent, + APIApplicationCommandInteractionMetadata, + APIAttachment, + APIAuthorizingIntegrationOwnersMap, APIButtonComponent, + APIChannelMention, APIChannelSelectComponent, APIContainerComponent, + APIEmoji, APIFileComponent, APIMediaGalleryComponent, APIMentionableSelectComponent, APIMessage, + APIMessageActivity, + APIMessageCall, + APIMessageComponentInteractionMetadata, + APIMessageReference, + APIModalSubmitInteractionMetadata, + APIReaction, + APIReactionCountDetails, APIRoleSelectComponent, APISectionComponent, APISeparatorComponent, @@ -24,14 +36,27 @@ import { SeparatorSpacingSize, ChannelType, SelectMenuDefaultValueType, + AttachmentFlags, + MessageActivityType, + InteractionType, } from 'discord-api-types/v10'; import { describe, expect, test } from 'vitest'; +import { + ApplicationCommandInteractionMetadata, + ChannelMention, + MessageActivity, + MessageCall, + MessageComponentInteractionMetadata, + MessageReference, + ModalSubmitInteractionMetadata, + Reaction, + ReactionCountDetails, +} from '../src/index.js'; import { Attachment } from '../src/messages/Attachment.js'; import { Message } from '../src/messages/Message.js'; -import { ContainerComponent } from '../src/messages/components/ContainerComponent.js'; -import { Embed } from '../src/messages/embeds/Embed.js'; import { User } from '../src/users/User.js'; import { dateToDiscordISOTimestamp } from '../src/utils/optimization.js'; +import { kPatch } from '../src/utils/symbols.js'; const user: APIUser = { username: 'user', @@ -44,6 +69,7 @@ const user: APIUser = { describe('message with embeds and attachments', () => { const timestamp = '2025-10-09T17:48:20.192000+00:00'; const data: APIMessage = { + application_id: '1231242356787654', id: DiscordSnowflake.generate({ timestamp: Date.parse(timestamp) }).toString(), type: MessageType.Default, position: 10, @@ -101,6 +127,7 @@ describe('message with embeds and attachments', () => { test('Message has all properties', () => { const instance = new Message(data); expect(instance.id).toBe(data.id); + expect(instance.applicationId).toBe(data.application_id); expect(instance.channelId).toBe(data.channel_id); expect(instance.position).toBe(data.position); expect(instance.content).toBe(data.content); @@ -128,17 +155,6 @@ describe('message with embeds and attachments', () => { expect(instances?.[0]?.proxyURL).toBe(data.attachments?.[0]?.proxy_url); }); - test('Embed sub-structure', () => { - const instances = data.embeds?.map((embed) => new Embed(embed)); - expect(instances?.map((embed) => embed.toJSON())).toEqual(data.embeds); - expect(instances?.[0]?.description).toBe(data.embeds?.[0]?.description); - expect(instances?.[0]?.color).toBe(data.embeds?.[0]?.color); - expect(instances?.[0]?.timestamp).toBe(Date.parse(data.embeds![0]!.timestamp!)); - expect(instances?.[0]?.title).toBe(data.embeds?.[0]?.title); - expect(instances?.[0]?.url).toBe(data.embeds?.[0]?.url); - expect(instances?.[0]?.type).toBe(data.embeds?.[0]?.type); - }); - test('User sub-structure', () => { const instance = new User(data.author); const instances = data.mentions.map((user) => new User(user)); @@ -457,22 +473,404 @@ describe('message with components', () => { expect(instance.toJSON()).toEqual(data); }); - test('Attachment sub-structure', () => { - const instances = data.attachments?.map((attachment) => new Attachment(attachment)); - expect(instances?.map((attachment) => attachment.toJSON())).toEqual(data.attachments); - expect(instances?.[0]?.description).toBe(data.attachments?.[0]?.description); - expect(instances?.[0]?.filename).toBe(data.attachments?.[0]?.filename); - expect(instances?.[0]?.id).toBe(data.attachments?.[0]?.id); - expect(instances?.[0]?.size).toBe(data.attachments?.[0]?.size); - expect(instances?.[0]?.url).toBe(data.attachments?.[0]?.url); - expect(instances?.[0]?.proxyURL).toBe(data.attachments?.[0]?.proxy_url); + describe('attachment sub-structure', () => { + const data: APIAttachment = { + id: '1230', + filename: 'the name of a file, it is', + title: 'title', + description: 'a very big attachment', + content_type: 'content/type', + size: 123, + url: 'https://discord.com/', + proxy_url: 'https://printer.discord.com/', + height: 10, + width: 10, + ephemeral: true, + duration_secs: 98, + waveform: 'ofjrjpfprenfo2npj3f034fpn43jf43;3ff5g2597y480f8u4jndoduie3f&====', + }; + + const attachmentWithFlagsData = { + ...data, + flags: AttachmentFlags.IsRemix, + }; + + const instance = new Attachment(data); + const attachmentWithFlags = new Attachment(attachmentWithFlagsData); + + test('has expected values for all getters', () => { + expect(instance.description).toBe(data.description); + expect(instance.filename).toBe(data.filename); + expect(instance.id).toBe(data.id); + expect(instance.size).toBe(data.size); + expect(instance.url).toBe(data.url); + expect(instance.proxyURL).toBe(data.proxy_url); + expect(instance.height).toBe(data.height); + expect(instance.width).toBe(data.width); + expect(instance.contentType).toBe(data.content_type); + expect(instance.ephemeral).toBe(data.ephemeral); + expect(instance.title).toBe(data.title); + expect(instance.durationSecs).toBe(data.duration_secs); + expect(instance.waveform).toBe(data.waveform); + expect(attachmentWithFlags.flags?.valueOf()).toBe(BigInt(attachmentWithFlagsData.flags)); + expect(instance.flags).toBeNull(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('Patching the attachment works in place', () => { + const filename = 'new filename'; + const size = 1_000_000; + + const patched = instance[kPatch]({ + filename, + size, + }); + + expect(patched.filename).toEqual(filename); + expect(patched.size).toEqual(size); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MessageActivity sub-structure', () => { + const data: APIMessageActivity = { + type: MessageActivityType.Listen, + }; + const instance = new MessageActivity(data); + + test('correct value for all getters', () => { + expect(instance.type).toBe(data.type); + + expect(instance.partyId).toBeUndefined(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const party_id = '123456765432'; + + const patched = instance[kPatch]({ + party_id, + }); + + expect(patched.partyId).toBe(party_id); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MessageCall sub-structure', () => { + const data: APIMessageCall = { + participants: ['1234456543456432', '438579082025724'], + ended_timestamp: '2025-10-10T15:50:20.292000+00:00', + }; + const instance = new MessageCall(data); + + test('correct value for all getters', () => { + const endedTimestamp = Date.parse(data.ended_timestamp!); + expect(instance.endedTimestamp).toBe(endedTimestamp); + expect(instance.endedAt!.valueOf()).toBe(endedTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const now = Date.parse(dateToDiscordISOTimestamp(new Date())); + + const patched = instance[kPatch]({ + ended_timestamp: dateToDiscordISOTimestamp(new Date(now)), + }); + + expect(instance.endedTimestamp).toBe(now); + expect(instance.endedAt!.valueOf()).toBe(now); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MessageComponentInteractionMetadata sub-structure', () => { + const authorizing_integration_owners: APIAuthorizingIntegrationOwnersMap = {}; + + const data: APIMessageComponentInteractionMetadata = { + user, + id: '1314343245325', + type: InteractionType.MessageComponent, + interacted_message_id: '3524355634542626', + original_response_message_id: '349875627935', + authorizing_integration_owners, + }; + const instance = new MessageComponentInteractionMetadata(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.interactedMessageId).toBe(data.interacted_message_id); + expect(instance.originalResponseMessageId).toBe(data.original_response_message_id); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const id = '1234'; + + const patched = instance[kPatch]({ + id, + }); + + expect(patched.id).toBe(id); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('MessageReference sub-structure', () => { + const data: APIMessageReference = { + type: MessageReferenceType.Default, + message_id: '12343425243655', + channel_id: '99999934525999', + guild_id: '34948539875437534875', + }; + const instance = new MessageReference(data); + + test('correct value for all getters', () => { + expect(instance.type).toBe(data.type); + expect(instance.messageId).toBe(data.message_id); + expect(instance.channelId).toBe(data.channel_id); + expect(instance.guildId).toBe(data.guild_id); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const channel_id = '11111111'; + + const patched = instance[kPatch]({ + channel_id, + }); + + expect(patched.channelId).toEqual(channel_id); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('Reaction sub-structure', () => { + const emoji: APIEmoji = { + animated: false, + id: '12345', + name: 'emoji', + }; + + const count_details: APIReactionCountDetails = { + normal: 12, + burst: 3, + }; + + const data: APIReaction = { + count: 15, + me: false, + me_burst: true, + burst_colors: ['#345324', '#543563'], + emoji, + count_details, + }; + const instance = new Reaction(data); + + test('correct value for all getters', () => { + expect(instance.burstColors).toEqual(data.burst_colors.map((x) => Number.parseInt(x, 16))); + expect(instance.count).toBe(data.count); + expect(instance.me).toBe(data.me); + expect(instance.meBurst).toBe(data.me_burst); + }); + + test('toJSON() is accurate', () => { + /** + * todo: the data for this structure does not accept "#color" + * but the toJSON here outputs it as "#color" and therefore + * does not pass. + */ + // expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const count = 2_000_000; + + const patched = instance[kPatch]({ + count, + }); + + expect(patched.count).toBe(count); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ReactionCountDetails sub-structure', () => { + const data: APIReactionCountDetails = { + normal: 12, + burst: 3, + }; + + const instance = new ReactionCountDetails(data); + + test('correct value for all getters', () => { + expect(instance.burst).toBe(data.burst); + expect(instance.normal).toBe(data.normal); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const burst = 200_000; + + const patched = instance[kPatch]({ + burst, + }); + + expect(patched.burst).toBe(burst); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ModalSubmitInteractionMetadata sub-structure', () => { + const authorizing_integration_owners: APIAuthorizingIntegrationOwnersMap = {}; + const triggering_interaction_metadata: APIApplicationCommandInteractionMetadata = { + user, + id: '1324567654', + type: InteractionType.ApplicationCommand, + authorizing_integration_owners, + target_user: user, + target_message_id: '32459872359698715', + original_response_message_id: '32598073425098735', + }; + + const data: APIModalSubmitInteractionMetadata = { + user, + id: '1314343245325', + type: InteractionType.ModalSubmit, + original_response_message_id: '349875627935', + triggering_interaction_metadata, + authorizing_integration_owners, + }; + + const instance = new ModalSubmitInteractionMetadata(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.originalResponseMessageId).toBe(data.original_response_message_id); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const original_response_message_id = '23423'; + + const patched = instance[kPatch]({ + original_response_message_id, + }); + + expect(patched.originalResponseMessageId).toEqual(original_response_message_id); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); }); - test('Component sub-structures', () => { - const containerInstance = new ContainerComponent(data.components?.[0] as APIContainerComponent); - expect(containerInstance.toJSON()).toEqual(container); - expect(containerInstance.type).toBe(container.type); - expect(containerInstance.id).toBe(container.id); - expect(containerInstance.spoiler).toBe(container.spoiler); + describe('ChannelMention sub-structure', () => { + const data: APIChannelMention = { + id: '2353408209582', + name: 'name for something', + guild_id: '23987234598735', + type: ChannelType.AnnouncementThread, + }; + + const instance = new ChannelMention(data); + + test('correct value for all getters', () => { + expect(instance.guildId).toBe(data.guild_id); + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const name = 'new name'; + + const patched = instance[kPatch]({ + name, + }); + + expect(patched.name).toBe(name); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('ApplicationCommandInteraction sub-structure', () => { + const authorizing_integration_owners: APIAuthorizingIntegrationOwnersMap = {}; + const data: APIApplicationCommandInteractionMetadata = { + user, + authorizing_integration_owners, + target_message_id: '345834597435345', + target_user: user, + id: '3453452345', + type: InteractionType.ApplicationCommand, + }; + + const instance = new ApplicationCommandInteractionMetadata(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.originalResponseMessageId).toBe(data.original_response_message_id); + expect(instance.targetMessageId).toBe(data.target_message_id); + expect(instance.type).toBe(data.type); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const id = '999999999999999999999'; + + const patched = instance[kPatch]({ + id, + }); + + expect(patched.id).toBe(id); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); }); }); diff --git a/packages/structures/__tests__/poll.test.ts b/packages/structures/__tests__/poll.test.ts new file mode 100644 index 000000000000..e1a178bf39d5 --- /dev/null +++ b/packages/structures/__tests__/poll.test.ts @@ -0,0 +1,169 @@ +import { + PollLayoutType, + type APIPoll, + type APIPollAnswer, + type APIPollAnswerCount, + type APIPollMedia, + type APIPollResults, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { Poll, PollAnswer, PollAnswerCount, PollMedia, PollResults } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols'; + +const answerCount: APIPollAnswerCount = { + id: 1, // not snowflake + count: 1, + me_voted: true, +}; + +const results: APIPollResults = { + is_finalized: false, + answer_counts: [answerCount], +}; + +const media: APIPollMedia = { + text: 'djs://poll-media-text', + emoji: { id: '1', name: 'djs://emoji-name' }, +}; + +const answer: APIPollAnswer = { + answer_id: 22, + poll_media: media, +}; + +const poll: APIPoll = { + question: media, + answers: [answer], + expiry: '2020-10-10T13:50:17.209000+00:00', + results, + allow_multiselect: true, + layout_type: PollLayoutType.Default, +}; + +describe('Poll structure and substructures', () => { + const data = poll; + const instance = new Poll(data); + + test('correct value for all getters', () => { + expect(instance.layoutType).toBe(data.layout_type); + expect(instance.allowMultiselect).toBe(data.allow_multiselect); + + expect(instance.expiresTimestamp).toBe(new Date(data.expiry!).getTime()); + expect(instance.expiresAt).toEqual(new Date(instance.expiresTimestamp!)); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + allow_multiselect: false, + }); + + expect(patched.allowMultiselect).toEqual(false); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + + describe('PollAnswer substructure', () => { + const data = answer; + const instance = new PollAnswer(data); + + test('correct value for all getters', () => { + expect(instance.answerId).toBe(data.answer_id); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + answer_id: 12, + }); + + expect(patched.answerId).toEqual(12); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('PollMedia substructure', () => { + const data = media; + const instance = new PollMedia(data); + + test('correct value for all getters', () => { + expect(instance.text).toBe(data.text); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + text: 'djs://[PATCHED]-poll-media', + }); + + expect(patched.text).toEqual('djs://[PATCHED]-poll-media'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('PollResults substructure', () => { + const data = results; + const instance = new PollResults(data); + + test('correct value for all getters', () => { + expect(instance.isFinalized).toBe(data.is_finalized); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + is_finalized: true, + }); + + expect(patched.isFinalized).toEqual(true); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('PollAnswerCount substructure', () => { + const data = answerCount; + const instance = new PollAnswerCount(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.count).toBe(data.count); + expect(instance.meVoted).toBe(data.me_voted); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + count: 111, + me_voted: false, + }); + + expect(patched.count).toEqual(111); + expect(patched.meVoted).toEqual(false); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); +}); diff --git a/packages/structures/__tests__/sku.test.ts b/packages/structures/__tests__/sku.test.ts new file mode 100644 index 000000000000..450f80e97491 --- /dev/null +++ b/packages/structures/__tests__/sku.test.ts @@ -0,0 +1,48 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { SKUFlags, SKUType, type APISKU } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { SKU } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const sku: APISKU = { + id: '1', + type: SKUType.Consumable, + application_id: '2', + name: 'djs://sku-name', + slug: 'djs://slug-name', + flags: SKUFlags.Available, +}; + +describe('SKU structure', () => { + const instance = new SKU(sku); + + test('correct value for all getters', () => { + expect(instance.applicationId).toBe(sku.application_id); + expect(instance.type).toBe(sku.type); + expect(instance.name).toBe(sku.name); + expect(instance.id).toBe(sku.id); + expect(instance.slug).toBe(sku.slug); + + expect(instance.flags?.bitField).toBe(BigInt(sku.flags)); + + expect(instance.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(instance.id!)); + expect(instance.createdDate).toEqual(new Date(instance.createdTimestamp!)); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(sku); + }); + + test('patching the structure works in-place', () => { + const patched = instance[kPatch]({ + name: 'djs://[PATCHED]-sku-name', + type: SKUType.Subscription, + }); + + expect(patched.name).toEqual('djs://[PATCHED]-sku-name'); + expect(patched.type).toEqual(SKUType.Subscription); + + expect(patched.toJSON()).not.toEqual(sku); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/soundboardsound.test.ts b/packages/structures/__tests__/soundboardsound.test.ts new file mode 100644 index 000000000000..4194718f9fa4 --- /dev/null +++ b/packages/structures/__tests__/soundboardsound.test.ts @@ -0,0 +1,72 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { type APISoundboardSound } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { SoundboardSound } from '../src'; +import { kPatch } from '../src/utils/symbols'; + +/** + * @todo - do we want to potentially not expose these getters and just have + * a partial Emoji structure for this structure? + * I think either way it could be beneficial to expose an Emoji for this structure + */ +const emoji_id = '1'; +const emoji_name = 'djs://emoji-name'; + +const soundboardSoundInGuild: APISoundboardSound = { + name: 'djs://soundboardsound-name', + sound_id: '1', + volume: 1_000_000_000, + guild_id: '1', + available: true, + emoji_id, + emoji_name, +}; + +const defaulSoundboardtSound: APISoundboardSound = { + name: 'djs://soundboardsound-name', + sound_id: '1', + volume: 1_000_000_000, + available: true, + emoji_id, + emoji_name, +}; + +describe('SoundboardSound structure', () => { + const guildSound = new SoundboardSound(soundboardSoundInGuild); + const defaultSound = new SoundboardSound(defaulSoundboardtSound); + + test('correct value for all getters and helper method [createdTimestamp | createdAt]', () => { + expect(guildSound.available).toBe(soundboardSoundInGuild.available); + expect(guildSound.emojiId).toBe(soundboardSoundInGuild.emoji_id); + expect(guildSound.emojiName).toBe(soundboardSoundInGuild.emoji_name); + expect(guildSound.guildId).toBe(soundboardSoundInGuild.guild_id); + expect(guildSound.name).toBe(soundboardSoundInGuild.name); + expect(guildSound.soundId).toBe(soundboardSoundInGuild.sound_id); + expect(guildSound.volume).toBe(soundboardSoundInGuild.volume); + + expect(guildSound.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(guildSound.soundId!)); + expect(guildSound.createdAt).toEqual(new Date(guildSound.createdTimestamp!)); + }); + + test('only sounds in guilds have the created[At|Timestamp] getters exposed', () => { + expect(defaultSound.createdAt).toBeNull(); + expect(defaultSound.createdTimestamp).toBeNull(); + expect(defaultSound.guildId).toBeUndefined(); + }); + + test('toJSON() returns expected values', () => { + expect(guildSound.toJSON()).toStrictEqual(soundboardSoundInGuild); + }); + + test('patching the structure works in-place', () => { + const patched = defaultSound[kPatch]({ + guild_id: '1', + }); + + expect(patched.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(guildSound.soundId!)); + expect(patched.createdAt).toEqual(new Date(guildSound.createdTimestamp!)); + + expect(patched.toJSON()).not.toEqual(defaulSoundboardtSound); + expect(patched).toBe(defaultSound); + }); +}); diff --git a/packages/structures/__tests__/stageinstance.test.ts b/packages/structures/__tests__/stageinstance.test.ts new file mode 100644 index 000000000000..806767d68fb6 --- /dev/null +++ b/packages/structures/__tests__/stageinstance.test.ts @@ -0,0 +1,51 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { StageInstancePrivacyLevel, type APIStageInstance } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { StageInstance } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const data: APIStageInstance = { + id: '43234543', + guild_id: '34579823414', + channel_id: '47239857823573', + topic: 'a very interesting topic', + discoverable_disabled: false, + guild_scheduled_event_id: '429874893572435', + privacy_level: StageInstancePrivacyLevel.GuildOnly, +}; + +describe('Stage Instance Structure', () => { + const instance = new StageInstance(data); + + test('correct value for all getters', () => { + expect(instance.channelId).toBe(data.channel_id); + expect(instance.guildId).toBe(data.guild_id); + expect(instance.guildScheduledEventId).toBe(data.guild_scheduled_event_id); + expect(instance.privacyLevel).toBe(data.privacy_level); + expect(instance.topic).toBe(data.topic); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const privacy_level = StageInstancePrivacyLevel.Public; + const topic = 'a slightly less interesting topic'; + + const patched = instance[kPatch]({ + privacy_level, + topic, + }); + + expect(patched.topic).toBe(topic); + expect(patched.privacyLevel).toBe(privacy_level); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/sticker.test.ts b/packages/structures/__tests__/sticker.test.ts new file mode 100644 index 000000000000..76f1258826c5 --- /dev/null +++ b/packages/structures/__tests__/sticker.test.ts @@ -0,0 +1,112 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { + type APIUser, + StickerFormatType, + StickerType, + type APISticker, + type APIStickerPack, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { Sticker, StickerPack } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const user: APIUser = { + id: '230523489548375', + username: 'idk', + discriminator: '0000', + global_name: 'idk', + avatar: '7256708436578243659397823786595968875', +}; + +const sticker: APISticker = { + id: '345543', + description: 'a lovely sticker', + pack_id: '43543262456', + name: 'some lovely stickers', + guild_id: '43539827528935', + tags: 'typescript-is-awersome', + type: StickerType.Guild, + format_type: StickerFormatType.GIF, + available: true, + sort_value: 45, + user, +}; + +const stickerPack: APIStickerPack = { + id: '23404858943543', + stickers: [sticker], + name: 'a lovely sticker pack', + sku_id: '4354354354355534554355435435', + description: "a lovely sticker pack's description", +}; + +describe('Sticker sub-structure', () => { + const data = sticker; + const instance = new Sticker(data); + + test('correct value for all getters', () => { + expect(instance.available).toBe(data.available); + expect(instance.description).toBe(data.description); + expect(instance.formatType).toBe(data.format_type); + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.tags).toBe(data.tags); + expect(instance.type).toBe(data.type); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const tags = 'is-html-a-language'; + + const patched = instance[kPatch]({ + tags, + }); + + expect(instance.tags).toEqual(tags); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); + +describe('StickerPack structure', () => { + const data = stickerPack; + const instance = new StickerPack(data); + + test('correct value for all getters', () => { + expect(instance.bannerAssetId).toBe(data.banner_asset_id); + expect(instance.coverStickerId).toBe(data.cover_sticker_id); + expect(instance.description).toBe(data.description); + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.skuId).toBe(data.sku_id); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const name = 'a lovely new name for a lovely sticker pack'; + + const patched = instance[kPatch]({ + name, + }); + + expect(patched.name).toEqual(name); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/subscription.test.ts b/packages/structures/__tests__/subscription.test.ts new file mode 100644 index 000000000000..4875910672bf --- /dev/null +++ b/packages/structures/__tests__/subscription.test.ts @@ -0,0 +1,76 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { type APISubscription, SubscriptionStatus } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { Subscription } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols'; + +const data: APISubscription = { + id: '34524354325', + user_id: '435435236464', + status: SubscriptionStatus.Active, + sku_ids: ['23423423423', '23423423423'], + entitlement_ids: ['23444', '242343242'], + renewal_sku_ids: ['234234423432345', '354354235423543'], + canceled_at: null, + current_period_end: '2099-10-10T15:50:17.209000+00:00', + current_period_start: '2023-10-10T15:50:17.209000+00:00', + country: 'GB', +}; + +describe('Subscription structure', () => { + const instance = new Subscription(data); + + test('correct value for all getters', () => { + expect(instance.country).toBe(data.country); + expect(instance.entitlementIds).toBe(data.entitlement_ids); + expect(instance.renewalSkuIds).toBe(data.renewal_sku_ids); + expect(instance.skuIds).toBe(data.sku_ids); + expect(instance.status).toBe(data.status); + expect(instance.userId).toBe(data.user_id); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + + const startsTimestamp = Date.parse(data.current_period_start!); + expect(instance.currentPeriodStartTimestamp).toBe(startsTimestamp); + expect(instance.currentPeriodStartAt!.valueOf()).toBe(startsTimestamp); + + const endsTimestamp = Date.parse(data.current_period_end!); + expect(instance.currentPeriodEndTimestamp).toBe(endsTimestamp); + expect(instance.currentPeriodEndsAt!.valueOf()).toBe(endsTimestamp); + + expect(instance.canceledAt).toBeNull(); + expect(instance.canceledTimestamp).toBeNull(); + }); + + test('toJSON() is accurate', () => { + const expectedData = data; + + // @ts-expect-error the dapi-types type here does not allow canceled_at to be + // optional and the toJSON() on Structures is written to omit values that are + // equivalent to null. Hence this is used as a workaround. + delete expectedData.canceled_at; + + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const canceled_at = '2099-10-10T15:50:17.209000+00:00'; + const country = 'NL'; + + const patched = instance[kPatch]({ + canceled_at, + country, + }); + + const canceledTimestamp = Date.parse(data.current_period_end!); + expect(instance.canceledTimestamp).toBe(canceledTimestamp); + expect(instance.canceledAt!.valueOf()).toBe(canceledTimestamp); + + expect(patched.country).toEqual(country); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/team.test.ts b/packages/structures/__tests__/team.test.ts new file mode 100644 index 000000000000..47eba17089b0 --- /dev/null +++ b/packages/structures/__tests__/team.test.ts @@ -0,0 +1,97 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { + type APIUser, + TeamMemberMembershipState, + TeamMemberRole, + type APITeam, + type APITeamMember, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { Team, TeamMember } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols'; + +const team_id = '32857243857805'; +const user: APIUser = { + id: '435426254365346', + global_name: 'Open Source is Cool!', + username: 'username', + discriminator: '0000', + avatar: '4564536345646543653hetrhtrhthrhertheh', +}; + +const teamMember: APITeamMember = { + user, + team_id, + membership_state: TeamMemberMembershipState.Accepted, + role: TeamMemberRole.Developer, + permissions: ['*'], +}; + +const team: APITeam = { + icon: 'dkfjdkjfdskaljfdsfhdas', + id: team_id, + members: [teamMember], + name: 'discord.js team', + owner_user_id: '2984509824358905', +}; + +describe('Team structure', () => { + const data = team; + const instance = new Team(data); + + test('correct value for all getters', () => { + expect(instance.icon).toBe(data.icon); + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.ownerUserId).toBe(data.owner_user_id); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const icon = '45y345y345y354y354y5344565464654365436'; + + const patched = instance[kPatch]({ + icon, + }); + + expect(patched.icon).toBe(icon); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + + describe('TeamMember structure', () => { + const data = teamMember; + const instance = new TeamMember(data); + + test('correct value for all getters', () => { + expect(instance.membershipState).toBe(data.membership_state); + expect(instance.role).toBe(data.role); + expect(instance.teamId).toBe(data.team_id); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const role = TeamMemberRole.Admin; + + const patched = instance[kPatch]({ + role, + }); + + expect(patched.role).toEqual(role); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); +}); diff --git a/packages/structures/__tests__/user.test.ts b/packages/structures/__tests__/user.test.ts new file mode 100644 index 000000000000..88d92a149d4e --- /dev/null +++ b/packages/structures/__tests__/user.test.ts @@ -0,0 +1,166 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { + type APIUser, + type APIAvatarDecorationData, + UserPremiumType, + type APIConnection, + ConnectionService, + ConnectionVisibility, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { User, AvatarDecorationData, Connection } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const avatarDecorationData: APIAvatarDecorationData = { + asset: '34525654654364635465346trtgwretre', + sku_id: '456546465437347', +}; + +const user: APIUser = { + username: 'idk', + id: '52435243526763', + global_name: 'test', + avatar: '34525654654364635465346trtgwretre', + discriminator: '0000', + accent_color: 123_456, + mfa_enabled: false, + locale: 'en-GB', + verified: false, + system: true, + premium_type: UserPremiumType.NitroBasic, +}; + +const connections: APIConnection = { + id: '52435243526763', + name: 'GitHub', + type: ConnectionService.GitHub, + revoked: false, + integrations: [ + { + id: '2343523453', + name: 'integration', + }, + ], + verified: true, + visibility: ConnectionVisibility.Everyone, + friend_sync: true, + show_activity: true, + two_way_link: false, +}; + +describe('User structure', () => { + const data = user; + const instance = new User(data); + + test('correct value for all getters', () => { + expect(instance.accentColor).toBe(data.accent_color); + expect(instance.avatar).toBe(data.avatar); + expect(instance.banner).toBe(data.banner); + expect(instance.discriminator).toBe(data.discriminator); + expect(instance.displayName).toBe(data.global_name ?? data.username); + expect(instance.hexAccentColor).toBe('#01e240'); + expect(instance.id).toBe(data.id); + expect(instance.locale).toBe(data.locale); + expect(instance.username).toBe(data.username); + expect(instance.premiumType).toBe(data.premium_type); + + const createdTimestamp = DiscordSnowflake.timestampFrom(instance.id!); + expect(instance.createdTimestamp).toBe(createdTimestamp); + expect(instance.createdAt!.valueOf()).toBe(createdTimestamp); + + expect(instance.bot).toBeFalsy(); + expect(instance.system).toBeTruthy(); + expect(instance.verified).toBeFalsy(); + expect(instance.email).toBeUndefined(); + expect(instance.mfaEnabled).toBeFalsy(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const premium_type = UserPremiumType.Nitro; + const mfa_enabled = true; + const locale = 'en-US'; + + const patched = instance[kPatch]({ + premium_type, + mfa_enabled, + locale, + }); + + expect(patched.premiumType).toEqual(premium_type); + expect(patched.mfaEnabled).toEqual(mfa_enabled); + expect(patched.locale).toEqual(locale); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + + describe('AvatarDecorationData sub-structure', () => { + const data = avatarDecorationData; + const instance = new AvatarDecorationData(data); + + test('correct value for all getters', () => { + expect(instance.asset).toBe(data.asset); + expect(instance.skuId).toBe(data.sku_id); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const asset = '52654653460892f8wfu0fihfoqiufhqrfhoqfh35236262546'; + + const patched = instance[kPatch]({ + asset, + }); + + expect(patched.asset).toEqual(asset); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); + + describe('Connection sub-structure', () => { + const data = connections; + const instance = new Connection(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + expect(instance.type).toBe(data.type); + expect(instance.visibility).toBe(data.visibility); + // eslint-disable-next-line n/no-sync + expect(instance.friendSync).toBe(data.friend_sync); + + expect(instance.revoked).toBeFalsy(); + expect(instance.verified).toBeTruthy(); + expect(instance.twoWayLink).toBeFalsy(); + expect(instance.showActivity).toBeTruthy(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const visibility = ConnectionVisibility.None; + const type = ConnectionService.AmazonMusic; + + const patched = instance[kPatch]({ + visibility, + type, + }); + + expect(patched.type).toEqual(type); + expect(patched.visibility).toEqual(visibility); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); + }); +}); diff --git a/packages/structures/__tests__/voice.test.ts b/packages/structures/__tests__/voice.test.ts new file mode 100644 index 000000000000..24c3d8915d9f --- /dev/null +++ b/packages/structures/__tests__/voice.test.ts @@ -0,0 +1,104 @@ +import type { APIVoiceState, APIVoiceRegion } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { VoiceState, VoiceRegion, dateToDiscordISOTimestamp } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const voiceState: APIVoiceState = { + guild_id: '26346565245', + channel_id: '467376575647', + user_id: '3657654746576457', + session_id: '35676764576457465', + deaf: false, + self_deaf: false, + self_mute: true, + self_video: true, + self_stream: false, + suppress: false, + request_to_speak_timestamp: '2023-10-10T15:50:17.209000+00:00', + mute: false, +}; + +const voiceRegion: APIVoiceRegion = { + id: 'c-lhr14-9283nc8e', + name: 'eu-west-2', + optimal: true, + deprecated: false, + custom: false, +}; + +describe('VoiceState structure', () => { + const data = voiceState; + const instance = new VoiceState(data); + + test('correct value for all getters', () => { + expect(instance.channelId).toBe(data.channel_id); + expect(instance.guildId).toBe(data.guild_id); + expect(instance.sessionId).toBe(data.session_id); + expect(instance.userId).toBe(data.user_id); + + expect(instance.deaf).toBeFalsy(); + expect(instance.mute).toBeFalsy(); + expect(instance.selfDeaf).toBeFalsy(); + expect(instance.suppress).toBeFalsy(); + expect(instance.selfMute).toBeTruthy(); + expect(instance.selfVideo).toBeTruthy(); + expect(instance.selfStream).toBeFalsy(); + + const requestToSpeakTimestamp = Date.parse(data.request_to_speak_timestamp!); + expect(instance.requestToSpeakTimestamp).toBe(dateToDiscordISOTimestamp(new Date(requestToSpeakTimestamp))); + expect(instance.requestToSpeakAt!.valueOf()).toBe(requestToSpeakTimestamp); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const self_video = false; + const mute = true; + + const patched = instance[kPatch]({ + self_video, + mute, + }); + + expect(patched.mute).toBeTruthy(); + expect(patched.selfVideo).toBeFalsy(); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); + +describe('VoiceRegion structure', () => { + const data = voiceRegion; + const instance = new VoiceRegion(data); + + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.name).toBe(data.name); + + expect(instance.custom).toBeFalsy(); + expect(instance.deprecated).toBeFalsy(); + expect(instance.optimal).toBeTruthy(); + }); + + test('toJSON() is accurate', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the structure works in-place', () => { + const name = 'a new name'; + + const patched = instance[kPatch]({ + deprecated: true, + name, + }); + + expect(patched.name).toEqual(name); + expect(patched.deprecated).toBeTruthy(); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/__tests__/webhook.test.ts b/packages/structures/__tests__/webhook.test.ts new file mode 100644 index 000000000000..0b77a1aeea6b --- /dev/null +++ b/packages/structures/__tests__/webhook.test.ts @@ -0,0 +1,51 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; +import { WebhookType, type APIWebhook } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { Webhook } from '../src/index.js'; +import { kPatch } from '../src/utils/symbols.js'; + +const data: APIWebhook = { + id: '1', + type: WebhookType.Incoming, + guild_id: 'djs://webhook-guild-id', + channel_id: 'djs://webhook-channel-id', + name: 'djs://webhook-name', + avatar: 'djs://webhook-avatar', + application_id: 'djs://webhook-application-id', + url: 'djs://webhook-url', +}; + +const instance = new Webhook(data); + +describe('Webhook structure', () => { + test('correct value for all getters', () => { + expect(instance.id).toBe(data.id); + expect(instance.type).toBe(data.type); + expect(instance.guildId).toBe(data.guild_id); + expect(instance.channelId).toBe(data.channel_id); + expect(instance.name).toBe(data.name); + expect(instance.avatar).toBe(data.avatar); + expect(instance.applicationId).toBe(data.application_id); + expect(instance.url).toBe(data.url); + + expect(instance.createdTimestamp).toBe(DiscordSnowflake.timestampFrom(instance.id!)); + expect(instance.createdAt).toEqual(new Date(instance.createdTimestamp!)); + }); + + test('toJSON() returns expected values', () => { + expect(instance.toJSON()).toStrictEqual(data); + }); + + test('patching the data works in place', () => { + const patched = instance[kPatch]({ + token: 'djs://webhook-token', + channel_id: 'djs://[PATCHED]-channel-id', + }); + + expect(patched.token).toEqual('djs://webhook-token'); + expect(patched.channelId).toEqual('djs://[PATCHED]-channel-id'); + + expect(patched.toJSON()).not.toEqual(data); + expect(patched).toBe(instance); + }); +}); diff --git a/packages/structures/src/messages/components/ButtonComponent.ts b/packages/structures/src/messages/components/ButtonComponent.ts index 468045cefdb6..cbbfa2419f49 100644 --- a/packages/structures/src/messages/components/ButtonComponent.ts +++ b/packages/structures/src/messages/components/ButtonComponent.ts @@ -1,5 +1,6 @@ import type { APIButtonComponent, APIButtonComponentWithCustomId, ButtonStyle } from 'discord-api-types/v10'; import { kData } from '../../utils/symbols.js'; +import { isFieldSet } from '../../utils/type-guards.js'; import type { Partialize } from '../../utils/types.js'; import { Component } from './Component.js'; @@ -36,6 +37,6 @@ export abstract class ButtonComponent< * The status of the button */ public get disabled() { - return typeof this[kData].disabled === 'boolean' ? this[kData].disabled : null; + return isFieldSet(this[kData], 'disabled', 'boolean') ? this[kData].disabled : null; } } diff --git a/packages/structures/src/messages/components/index.ts b/packages/structures/src/messages/components/index.ts index fdcc1e4bd752..ff85bdab5b73 100644 --- a/packages/structures/src/messages/components/index.ts +++ b/packages/structures/src/messages/components/index.ts @@ -10,6 +10,7 @@ export * from './LinkButtonComponent.js'; export * from './MediaGalleryComponent.js'; export * from './MentionableSelectMenuComponent.js'; export * from './LabeledButtonComponent.js'; +export * from './LabelComponent.js'; export * from './PremiumButtonComponent.js'; export * from './RoleSelectMenuComponent.js'; export * from './SectionComponent.js'; diff --git a/packages/structures/src/skus/SKU.ts b/packages/structures/src/skus/SKU.ts index 7d81847f05a9..9786ade29e18 100644 --- a/packages/structures/src/skus/SKU.ts +++ b/packages/structures/src/skus/SKU.ts @@ -1,8 +1,9 @@ +import { DiscordSnowflake } from '@sapphire/snowflake'; import type { SKUFlags, APISKU } from 'discord-api-types/v10'; import { Structure } from '../Structure.js'; import { SKUFlagsBitField } from '../bitfields/SKUFlagsBitField.js'; import { kData } from '../utils/symbols.js'; -import { isFieldSet } from '../utils/type-guards.js'; +import { isIdSet, isFieldSet } from '../utils/type-guards.js'; import type { Partialize } from '../utils/types.js'; /** @@ -66,4 +67,19 @@ export class SKU extends Structure extends Structure { +export class VoiceState extends Structure< + APIVoiceState, + Omitted +> { /** * The template used for removing data from the raw data stored for each voice state */ - public static override readonly DataTemplate: Partial = {}; + public static override readonly DataTemplate: Partial = { + set request_to_speak_timestamp(_: string) {}, + }; + + protected [kRequestToSpeakTimestamp]: number | null = null; /** * @param data - The raw data received from the API for the voice state */ public constructor(data: Partialize) { super(data); + this.optimizeData(data); + } + + /** + * {@inheritDoc Structure.optimizeData} + */ + public override optimizeData(data: Partial) { + if (data.request_to_speak_timestamp) { + this[kRequestToSpeakTimestamp] = Date.parse(data.request_to_speak_timestamp); + } } /** @@ -100,9 +118,31 @@ export class VoiceState extends S } /** - * The time at which the user requested to speak + * The timestamp at which the user requested to speak */ public get requestToSpeakTimestamp() { - return this[kData].request_to_speak_timestamp; + return this[kRequestToSpeakTimestamp] ? dateToDiscordISOTimestamp(new Date(this[kRequestToSpeakTimestamp])) : null; + } + + /** + * The time at which the user requested to speak + */ + public get requestToSpeakAt() { + return this.requestToSpeakTimestamp ? new Date(this.requestToSpeakTimestamp) : null; + } + + /** + * {@inheritDoc Structure.toJSON} + */ + public override toJSON() { + const clone = super.toJSON(); + + const requestToSpeakTimestamp = this[kRequestToSpeakTimestamp]; + + if (requestToSpeakTimestamp) { + clone.request_to_speak_timestamp = dateToDiscordISOTimestamp(new Date(requestToSpeakTimestamp)); + } + + return clone; } }