fix: poll builders (#10783)

* fix: poll builders

- Fixed validations
- Added missing documentation
- Removed redundant code
- Consistency™️

* fix: tests

* feat: missing answers test
This commit is contained in:
Almeida
2025-03-01 14:57:00 +00:00
committed by GitHub
parent 88bfeaab22
commit d1f56ffb2a
7 changed files with 73 additions and 33 deletions

View File

@@ -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 { describe, test, expect } from 'vitest';
import { PollAnswerMediaBuilder, PollBuilder, PollQuestionBuilder } from '../../src/index.js'; import { PollAnswerMediaBuilder, PollBuilder, PollQuestionBuilder } from '../../src/index.js';
@@ -7,22 +7,33 @@ const dummyData = {
text: '.', text: '.',
}, },
answers: [], answers: [],
}; } satisfies RESTAPIPoll;
const dummyDataWithAnswer = {
...dummyData,
answers: [
{
poll_media: {
text: '.',
},
},
],
} satisfies RESTAPIPoll;
describe('Poll', () => { describe('Poll', () => {
describe('Poll question', () => { describe('Poll question', () => {
test('GIVEN a poll with pre-defined question text THEN return valid toJSON data', () => { 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', () => { 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' }); 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', () => { test('GIVEN a poll with invalid question THEN throws error', () => {
@@ -32,21 +43,21 @@ describe('Poll', () => {
describe('Poll duration', () => { describe('Poll duration', () => {
test('GIVEN a poll with pre-defined duration THEN return valid toJSON data', () => { 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', () => { test('GIVEN a poll with duration THEN return valid toJSON data', () => {
const poll = new PollBuilder(dummyData); const poll = new PollBuilder(dummyDataWithAnswer);
poll.setDuration(1); 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', () => { 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(); expect(() => poll.setDuration(999).toJSON()).toThrowError();
}); });
@@ -54,21 +65,21 @@ describe('Poll', () => {
describe('Poll layout type', () => { describe('Poll layout type', () => {
test('GIVEN a poll with pre-defined layout type THEN return valid toJSON data', () => { 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', () => { 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); 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', () => { 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 // @ts-expect-error Invalid layout type
expect(() => poll.setLayoutType(-1).toJSON()).toThrowError(); expect(() => poll.setLayoutType(-1).toJSON()).toThrowError();
@@ -77,21 +88,21 @@ describe('Poll', () => {
describe('Poll multi select', () => { describe('Poll multi select', () => {
test('GIVEN a poll with pre-defined multi select enabled THEN return valid toJSON data', () => { 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', () => { 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(); 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', () => { 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 // @ts-expect-error Invalid multi-select value
expect(() => poll.setMultiSelect('string').toJSON()).toThrowError(); expect(() => poll.setMultiSelect('string').toJSON()).toThrowError();
@@ -99,6 +110,12 @@ describe('Poll', () => {
}); });
describe('Poll answers', () => { describe('Poll answers', () => {
test('GIVEN a poll without answers THEN throws error', () => {
const poll = new PollBuilder(dummyData);
expect(() => poll.toJSON()).toThrowError();
});
test('GIVEN a poll with pre-defined answer THEN returns valid toJSON data', () => { test('GIVEN a poll with pre-defined answer THEN returns valid toJSON data', () => {
const poll = new PollBuilder({ const poll = new PollBuilder({
...dummyData, ...dummyData,

View File

@@ -6,14 +6,14 @@ export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300)
export const pollAnswerMediaPredicate = z.object({ export const pollAnswerMediaPredicate = z.object({
text: z.string().min(1).max(55), text: z.string().min(1).max(55),
emoji: emojiPredicate.nullish(), emoji: emojiPredicate.optional(),
}); });
export const pollAnswerPredicate = z.object({ poll_media: pollAnswerMediaPredicate }); export const pollAnswerPredicate = z.object({ poll_media: pollAnswerMediaPredicate });
export const pollPredicate = z.object({ export const pollPredicate = z.object({
question: pollQuestionPredicate, question: pollQuestionPredicate,
answers: z.array(pollAnswerPredicate).max(10), answers: z.array(pollAnswerPredicate).min(1).max(10),
duration: z.number().min(1).max(768).optional(), duration: z.number().min(1).max(768).optional(),
allow_multiselect: z.boolean().optional(), allow_multiselect: z.boolean().optional(),
layout_type: z.nativeEnum(PollLayoutType).optional(), layout_type: z.nativeEnum(PollLayoutType).optional(),

View File

@@ -161,7 +161,7 @@ export class PollBuilder implements JSONEncodable<RESTAPIPoll> {
* @param updater - The function to update the question with * @param updater - The function to update the question with
*/ */
public updateQuestion(updater: (builder: PollQuestionBuilder) => void): this { public updateQuestion(updater: (builder: PollQuestionBuilder) => void): this {
updater((this.data.question ??= new PollQuestionBuilder())); updater(this.data.question);
return this; return this;
} }

View File

@@ -9,8 +9,16 @@ export interface PollAnswerData extends Omit<APIPollAnswer, 'answer_id' | 'poll_
} }
export class PollAnswerBuilder { export class PollAnswerBuilder {
/**
* The API data associated with this poll answer.
*/
protected readonly data: PollAnswerData; 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'>> = {}) { public constructor(data: Partial<Omit<APIPollAnswer, 'answer_id'>> = {}) {
this.data = { this.data = {
...structuredClone(data), ...structuredClone(data),
@@ -35,8 +43,9 @@ export class PollAnswerBuilder {
* *
* @param updater - The function to update the media with * @param updater - The function to update the media with
*/ */
public updateMedia(updater: (builder: PollAnswerMediaBuilder) => void) { public updateMedia(updater: (builder: PollAnswerMediaBuilder) => void): this {
updater((this.data.poll_media ??= new PollAnswerMediaBuilder())); updater(this.data.poll_media);
return this;
} }
/** /**
@@ -47,10 +56,12 @@ export class PollAnswerBuilder {
* @param validationOverride - Force validation to run/not run regardless of your global preference * @param validationOverride - Force validation to run/not run regardless of your global preference
*/ */
public toJSON(validationOverride?: boolean): Omit<APIPollAnswer, 'answer_id'> { public toJSON(validationOverride?: boolean): Omit<APIPollAnswer, 'answer_id'> {
const { poll_media, ...rest } = this.data;
const data = { const data = {
...structuredClone(this.data), ...structuredClone(rest),
// Disable validation because the pollAnswerPredicate below will validate this as well // 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); validate(pollAnswerPredicate, data, validationOverride);

View File

@@ -4,11 +4,11 @@ import { pollAnswerMediaPredicate } from './Assertions.js';
import { PollMediaBuilder } from './PollMedia.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 { 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 * @param emoji - The emoji to use
*/ */
@@ -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 { public clearEmoji(): this {
this.data.emoji = undefined; this.data.emoji = undefined;
return this; return this;
} }
/**
* {@inheritDoc PollMediaBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIPollMedia { public override toJSON(validationOverride?: boolean): APIPollMedia {
const clone = structuredClone(this.data); const clone = structuredClone(this.data);
validate(pollAnswerMediaPredicate, clone, validationOverride); validate(pollAnswerMediaPredicate, clone, validationOverride);
return clone as APIPollMedia; return clone;
} }
} }

View File

@@ -1,6 +1,12 @@
import type { APIPollMedia } from 'discord-api-types/v10'; 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 { export abstract class PollMediaBuilder {
/**
* The API data associated with this poll media.
*/
protected readonly data: Partial<APIPollMedia>; protected readonly data: Partial<APIPollMedia>;
/** /**

View File

@@ -7,11 +7,14 @@ import { PollMediaBuilder } from './PollMedia.js';
* A builder that creates API-compatible JSON data for a poll question. * A builder that creates API-compatible JSON data for a poll question.
*/ */
export class PollQuestionBuilder extends PollMediaBuilder { export class PollQuestionBuilder extends PollMediaBuilder {
/**
* {@inheritDoc PollMediaBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): Omit<APIPollMedia, 'emoji'> { public override toJSON(validationOverride?: boolean): Omit<APIPollMedia, 'emoji'> {
const clone = structuredClone(this.data); const clone = structuredClone(this.data);
validate(pollQuestionPredicate, clone, validationOverride); validate(pollQuestionPredicate, clone, validationOverride);
return clone as Omit<APIPollMedia, 'emoji'>; return clone;
} }
} }