Skip to content

fix: poll builders #10783

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions packages/builders/__tests__/messages/poll.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PollLayoutType } from 'discord-api-types/v10';
import { PollLayoutType, type RESTAPIPoll } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { PollAnswerMediaBuilder, PollBuilder, PollQuestionBuilder } from '../../src/index.js';

Expand All @@ -7,22 +7,33 @@ const dummyData = {
text: '.',
},
answers: [],
};
} satisfies RESTAPIPoll;

const dummyDataWithAnswer = {
...dummyData,
answers: [
{
poll_media: {
text: '.',
},
},
],
} satisfies RESTAPIPoll;

describe('Poll', () => {
describe('Poll question', () => {
test('GIVEN a poll with pre-defined question text THEN return valid toJSON data', () => {
const poll = new PollBuilder({ question: { text: 'foo' } });
const poll = new PollBuilder({ ...dummyDataWithAnswer, question: { text: 'foo' } });

expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } });
expect(poll.toJSON()).toStrictEqual({ ...dummyDataWithAnswer, question: { text: 'foo' } });
});

test('GIVEN a poll with question text THEN return valid toJSON data', () => {
const poll = new PollBuilder();
const poll = new PollBuilder(dummyDataWithAnswer);

poll.setQuestion({ text: 'foo' });

expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } });
expect(poll.toJSON()).toStrictEqual({ ...dummyDataWithAnswer, question: { text: 'foo' } });
});

test('GIVEN a poll with invalid question THEN throws error', () => {
Expand All @@ -32,43 +43,43 @@ describe('Poll', () => {

describe('Poll duration', () => {
test('GIVEN a poll with pre-defined duration THEN return valid toJSON data', () => {
const poll = new PollBuilder({ duration: 1, ...dummyData });
const poll = new PollBuilder({ duration: 1, ...dummyDataWithAnswer });

expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyDataWithAnswer });
});

test('GIVEN a poll with duration THEN return valid toJSON data', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

poll.setDuration(1);

expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyDataWithAnswer });
});

test('GIVEN a poll with invalid duration THEN throws error', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

expect(() => poll.setDuration(999).toJSON()).toThrowError();
});
});

describe('Poll layout type', () => {
test('GIVEN a poll with pre-defined layout type THEN return valid toJSON data', () => {
const poll = new PollBuilder({ layout_type: PollLayoutType.Default, ...dummyData });
const poll = new PollBuilder({ ...dummyDataWithAnswer, layout_type: PollLayoutType.Default });

expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyDataWithAnswer });
});

test('GIVEN a poll with layout type THEN return valid toJSON data', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

poll.setLayoutType(PollLayoutType.Default);

expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyDataWithAnswer });
});

test('GIVEN a poll with invalid layout type THEN throws error', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

// @ts-expect-error Invalid layout type
expect(() => poll.setLayoutType(-1).toJSON()).toThrowError();
Expand All @@ -77,21 +88,21 @@ describe('Poll', () => {

describe('Poll multi select', () => {
test('GIVEN a poll with pre-defined multi select enabled THEN return valid toJSON data', () => {
const poll = new PollBuilder({ allow_multiselect: true, ...dummyData });
const poll = new PollBuilder({ allow_multiselect: true, ...dummyDataWithAnswer });

expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyDataWithAnswer });
});

test('GIVEN a poll with multi select enabled THEN return valid toJSON data', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

poll.setMultiSelect();

expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyData });
expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyDataWithAnswer });
});

test('GIVEN a poll with invalid multi select value THEN throws error', () => {
const poll = new PollBuilder(dummyData);
const poll = new PollBuilder(dummyDataWithAnswer);

// @ts-expect-error Invalid multi-select value
expect(() => poll.setMultiSelect('string').toJSON()).toThrowError();
Expand Down
4 changes: 2 additions & 2 deletions packages/builders/src/messages/poll/Assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300)

export const pollAnswerMediaPredicate = z.object({
text: z.string().min(1).max(55),
emoji: emojiPredicate.nullish(),
emoji: emojiPredicate.optional(),
});

export const pollAnswerPredicate = z.object({ poll_media: pollAnswerMediaPredicate });

export const pollPredicate = z.object({
question: pollQuestionPredicate,
answers: z.array(pollAnswerPredicate).max(10),
answers: z.array(pollAnswerPredicate).min(1).max(10),
duration: z.number().min(1).max(768).optional(),
allow_multiselect: z.boolean().optional(),
layout_type: z.nativeEnum(PollLayoutType).optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/builders/src/messages/poll/Poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class PollBuilder implements JSONEncodable<RESTAPIPoll> {
* @param updater - The function to update the question with
*/
public updateQuestion(updater: (builder: PollQuestionBuilder) => void): this {
updater((this.data.question ??= new PollQuestionBuilder()));
updater(this.data.question);
return this;
}

Expand Down
19 changes: 15 additions & 4 deletions packages/builders/src/messages/poll/PollAnswer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ export interface PollAnswerData extends Omit<APIPollAnswer, 'answer_id' | 'poll_
}

export class PollAnswerBuilder {
/**
* The API data associated with this poll answer.
*/
protected readonly data: PollAnswerData;

/**
* Creates a new poll answer from API data.
*
* @param data - The API data to create this poll answer with
*/
public constructor(data: Partial<Omit<APIPollAnswer, 'answer_id'>> = {}) {
this.data = {
...structuredClone(data),
Expand All @@ -35,8 +43,9 @@ export class PollAnswerBuilder {
*
* @param updater - The function to update the media with
*/
public updateMedia(updater: (builder: PollAnswerMediaBuilder) => void) {
updater((this.data.poll_media ??= new PollAnswerMediaBuilder()));
public updateMedia(updater: (builder: PollAnswerMediaBuilder) => void): this {
updater(this.data.poll_media);
return this;
}

/**
Expand All @@ -47,10 +56,12 @@ export class PollAnswerBuilder {
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): Omit<APIPollAnswer, 'answer_id'> {
const { poll_media, ...rest } = this.data;

const data = {
...structuredClone(this.data),
...structuredClone(rest),
// Disable validation because the pollAnswerPredicate below will validate this as well
poll_media: this.data.poll_media?.toJSON(false),
poll_media: poll_media.toJSON(false),
};

validate(pollAnswerPredicate, data, validationOverride);
Expand Down
11 changes: 7 additions & 4 deletions packages/builders/src/messages/poll/PollAnswerMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { pollAnswerMediaPredicate } from './Assertions.js';
import { PollMediaBuilder } from './PollMedia.js';

/**
* A builder that creates API-compatible JSON data for poll answers.
* A builder that creates API-compatible JSON data for the media of a poll answer.
*/
export class PollAnswerMediaBuilder extends PollMediaBuilder {
/**
* Sets the emoji for this poll answer.
* Sets the emoji for this poll answer media.
*
* @param emoji - The emoji to use
*/
Expand All @@ -18,18 +18,21 @@ export class PollAnswerMediaBuilder extends PollMediaBuilder {
}

/**
* Clears the emoji for this poll answer.
* Clears the emoji for this poll answer media.
*/
public clearEmoji(): this {
this.data.emoji = undefined;
return this;
}

/**
* {@inheritDoc PollMediaBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIPollMedia {
const clone = structuredClone(this.data);

validate(pollAnswerMediaPredicate, clone, validationOverride);

return clone as APIPollMedia;
return clone;
}
}
6 changes: 6 additions & 0 deletions packages/builders/src/messages/poll/PollMedia.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { APIPollMedia } from 'discord-api-types/v10';

/**
* The base poll media builder that contains common symbols for poll media builders.
*/
export abstract class PollMediaBuilder {
/**
* The API data associated with this poll media.
*/
protected readonly data: Partial<APIPollMedia>;

/**
Expand Down
5 changes: 4 additions & 1 deletion packages/builders/src/messages/poll/PollQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import { PollMediaBuilder } from './PollMedia.js';
* A builder that creates API-compatible JSON data for a poll question.
*/
export class PollQuestionBuilder extends PollMediaBuilder {
/**
* {@inheritDoc PollMediaBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): Omit<APIPollMedia, 'emoji'> {
const clone = structuredClone(this.data);

validate(pollQuestionPredicate, clone, validationOverride);

return clone as Omit<APIPollMedia, 'emoji'>;
return clone;
}
}