diff --git a/api/lib/get-fields-update-query.js b/api/lib/get-fields-update-query.js index a71ec919..081b27f1 100644 --- a/api/lib/get-fields-update-query.js +++ b/api/lib/get-fields-update-query.js @@ -57,7 +57,7 @@ export function getFieldsUpdateQuery(state, fields) { value === null ? "" : "optionsByValue" in field - ? findFieldOptionId(state, field.optionsByValue, value) + ? findFieldOptionId(state, field, value) : value; const queryNodes = @@ -84,20 +84,41 @@ export function getFieldsUpdateQuery(state, fields) { } function escapeQuotes(str) { + // TODO: add test for when `str` is not a "string" + /* c8 ignore next */ return typeof str === "string" ? str.replace(/\"/g, '\\"') : str; } /** * @param {import("../..").GitHubProjectStateWithFields | import("../..").GitHubProjectStateWithItems} state - * @param {Record} optionsByValue + * @param {import("../..").ProjectFieldWithOptions} field * @param {string} value * - * @returns {string | undefined} + * @returns {string} */ -function findFieldOptionId(state, optionsByValue, value) { +function findFieldOptionId(state, field, value) { const [_optionValue, optionId] = - Object.entries(optionsByValue).find(([optionValue]) => + Object.entries(field.optionsByValue).find(([optionValue]) => state.matchFieldOptionValue(optionValue, value.trim()) ) || []; + + if (!optionId) { + const knownOptions = Object.keys(field.optionsByValue); + const existingOptionsString = knownOptions + .map((value) => `- ${value}`) + .join("\n"); + + throw Object.assign( + new Error( + `[github-project] "${value}" is an invalid option for "${field.name}".\n\nKnown options are:\n${existingOptionsString}` + ), + { + code: "E_GITHUB_PROJECT_UNKNOWN_FIELD_OPTION", + knownOptions, + userOption: value, + } + ); + } + return optionId; } diff --git a/test.js b/test.js index 48720219..ff7953ff 100644 --- a/test.js +++ b/test.js @@ -1350,7 +1350,7 @@ test("project.items.update(itemNodeId, fields) not found", async (t) => { }); const updatedItem = await project.items.update("", { - relevantToUsers: "yes", + relevantToUsers: "Yes", }); t.deepEqual(updatedItem, undefined); @@ -1415,7 +1415,7 @@ test("project.items.update(itemNodeId, fields) unforeseen GraphQL error", async try { await project.items.update("", { - relevantToUsers: "yes", + relevantToUsers: "Yes", }); t.fail("Should have thrown"); } catch (error) { @@ -1467,7 +1467,7 @@ test("project.items.update(itemNodeId, fields) with non GraphQL error", async (t try { await project.items.update("", { - relevantToUsers: "yes", + relevantToUsers: "Yes", }); t.fail("should have thrown"); } catch (error) { @@ -1700,6 +1700,66 @@ test("project.items.update(itemNodeId, fields) with custom status field", async }); }); +test("project.items.update(itemNodeId, fields) with invalid field option", async (t) => { + const { getProjectFieldsQueryResultFixture } = await import( + "./test/fixtures/get-project-fields/query-result.js" + ); + const { issueItemFixture } = await import( + "./test/fixtures/get-item/issue-item.js" + ); + + const octokit = new Octokit(); + octokit.hook.wrap("request", async (request, options) => { + t.deepEqual(options.method, "POST"); + t.deepEqual(options.url, "/graphql"); + + if (/query getProjectCoreData\(/.test(options.query)) { + t.deepEqual(options.variables, { + org: "org", + number: 1, + }); + + return { + data: getProjectFieldsQueryResultFixture, + }; + } + + throw new Error( + `Unexpected query:\n${prettier.format(options.query, { + parser: "graphql", + })}` + ); + }); + + const project = new GitHubProject({ + org: "org", + number: 1, + octokit, + fields: { + status: "Relevant to users?", + }, + }); + + try { + await project.items.update("PNI_lADOBYMIeM0lfM4ADfm9", { + status: "Unknown option", + }); + t.fail("Should not resolve"); + } catch (error) { + t.is(error.code, "E_GITHUB_PROJECT_UNKNOWN_FIELD_OPTION"); + t.deepEqual(error.knownOptions, ["Yes", "No"]); + t.is(error.userOption, "Unknown option"); + t.is( + error.message, + `[github-project] "Unknown option" is an invalid option for "Relevant to users?". + +Known options are: +- Yes +- No` + ); + } +}); + test("project.items.updateByContentId(contentNodeId, fields)", async (t) => { const { getProjectItemsQueryResultFixture } = await import( "./test/fixtures/get-project-items/query-result.js" @@ -1754,7 +1814,7 @@ test("project.items.updateByContentId(contentNodeId, fields)", async (t) => { const updatedItem = await project.items.updateByContentId( "I_kwDOGNkQys49IizC", { - relevantToUsers: "yes", + relevantToUsers: "Yes", } ); @@ -1762,7 +1822,7 @@ test("project.items.updateByContentId(contentNodeId, fields)", async (t) => { ...issueItemFixture, fields: { ...issueItemFixture.fields, - relevantToUsers: "yes", + relevantToUsers: "Yes", }, }); }); @@ -1816,7 +1876,7 @@ test("project.items.updateByContentId(contentNodeId, fields) not found", async ( }); const updatedItem = await project.items.updateByContentId("", { - relevantToUsers: "yes", + relevantToUsers: "Yes", }); t.deepEqual(updatedItem, undefined); @@ -1877,7 +1937,7 @@ test("project.items.updateByContentRepositoryAndNumber(contentNodeId, fields)", "example-product", 2, { - relevantToUsers: "yes", + relevantToUsers: "Yes", } ); @@ -1885,7 +1945,7 @@ test("project.items.updateByContentRepositoryAndNumber(contentNodeId, fields)", ...issueItemFixture, fields: { ...issueItemFixture.fields, - relevantToUsers: "yes", + relevantToUsers: "Yes", }, }); });