Files
freee__freee-mcp/scripts/generate-references.test.ts
T
him0 da8871950a chore: README の対応操作数を OpenAPI スキーマから自動更新する
README に載せている対応操作数を手で数え直さずに済むよう、
generate:references が OpenAPI スキーマから再計算してマーカー間を置き換える。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 10:59:22 +09:00

432 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildEndpointsMarkdown,
countOperations,
replaceOperationCount,
type OpenAPISchema,
type PathData,
} from "./generate-references";
/**
* `POST /tags` と `PUT /tags/{id}` が同じ `tagParams` を共有しつつ、
* body 自体の必須性だけが異なる実際のスキーマ(accounting)を模したもの。
*/
const schema: OpenAPISchema = {
paths: {},
components: {
schemas: {
tagParams: {
type: "object",
required: ["company_id", "name"],
properties: {
company_id: { type: "integer", description: "事業所ID" },
name: { type: "string", description: "メモタグ名" },
},
},
},
},
};
function endpointsWithBodyRequired(
...required: Array<boolean | undefined>
): PathData[] {
return required.map((value, index) => ({
path: index === 0 ? "/tags" : "/tags/{id}",
operations: [
{
method: index === 0 ? "POST" : "PUT",
requestBody: {
required: value,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/tagParams" },
},
},
},
},
],
}));
}
describe("buildEndpointsMarkdown", () => {
it("requestBody.required: true を見出しの * で示す", () => {
const md = buildEndpointsMarkdown(schema, endpointsWithBodyRequired(true));
expect(md).toContain("### リクエストボディ*");
expect(md).toContain("- company_id*: integer - 事業所ID");
});
it("requestBody.required が false / 未指定なら * を付けない", () => {
for (const value of [false, undefined]) {
const md = buildEndpointsMarkdown(schema, endpointsWithBodyRequired(value));
expect(md).toContain("### リクエストボディ");
expect(md).not.toContain("### リクエストボディ*");
}
});
it("同じ schema でも required が異なれば重複排除で潰さない", () => {
// POST /tags は required: true、PUT /tags/{id} は false。
// body の中身は同一だが、body 自体の必須性が違うので両方出す必要がある。
const md = buildEndpointsMarkdown(
schema,
endpointsWithBodyRequired(true, false)
);
expect(md).toContain("### リクエストボディ*");
expect(md).toContain("### リクエストボディ\n\n- company_id*");
expect(md).not.toContain("POST /tags と同じ");
});
it("required も本文も同一なら初出への参照に置き換える", () => {
const md = buildEndpointsMarkdown(
schema,
endpointsWithBodyRequired(true, true)
);
expect(md).toContain("### リクエストボディ*\n\nPOST /tags と同じ");
});
});
/**
* 販売管理 API のように requestBody を components.requestBodies に切り出し、
* 共通項目を `allOf` で合成しているスキーマを模したもの。
*/
const composedSchema: OpenAPISchema = {
paths: {},
components: {
schemas: {
WithCompanyId: {
type: "object",
required: ["company_id"],
properties: {
company_id: { type: "integer", description: "事業所ID", minimum: 1 },
},
},
BusinessRequestBase: {
type: "object",
properties: {
name: { type: "string", description: "案件名称" },
code: { type: "string", description: "案件コード" },
},
},
UserId: { type: "integer", format: "int64", example: 1 },
},
requestBodies: {
BusinessCreateRequest: {
required: true,
content: {
"application/json": {
schema: {
allOf: [
{ $ref: "#/components/schemas/WithCompanyId" },
{ $ref: "#/components/schemas/BusinessRequestBase" },
{ type: "object", required: ["name"] },
],
},
},
},
},
},
},
};
const composedEndpoints: PathData[] = [
{
path: "/businesses",
operations: [
{
method: "POST",
requestBody: { $ref: "#/components/requestBodies/BusinessCreateRequest" },
},
],
},
];
describe("buildEndpointsMarkdown - $ref / allOf の解決", () => {
it("components.requestBodies への $ref を解決して本文を出力する", () => {
const md = buildEndpointsMarkdown(composedSchema, composedEndpoints);
// 解決前は content が読めず、セクションごと欠落していた
expect(md).toContain("### リクエストボディ*");
expect(md).toContain("- name*: string - 案件名称");
});
it("複数要素の allOf を合成し、required は和集合を取る", () => {
const md = buildEndpointsMarkdown(composedSchema, composedEndpoints);
// company_id は 1 つ目、name は 3 つ目のメンバーで required 指定されている
expect(md).toContain("- company_id*: integer - 事業所ID");
expect(md).toContain("- name*: string - 案件名称");
expect(md).toContain("- code: string - 案件コード");
});
it("allOf の合成で制約・例・format を落とさない", () => {
const md = buildEndpointsMarkdown(composedSchema, composedEndpoints);
expect(md).toContain("(最小: 1)");
});
it("allOf ラッパーの description は参照先より優先する", () => {
const md = buildEndpointsMarkdown(
{
paths: {},
components: {
schemas: composedSchema.components?.schemas,
requestBodies: {
Body: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
executor_id: {
description: "退会処理を実行するユーザーのID",
allOf: [{ $ref: "#/components/schemas/UserId" }],
},
},
},
},
},
},
},
},
},
[
{
path: "/users",
operations: [
{
method: "POST",
requestBody: { $ref: "#/components/requestBodies/Body" },
},
],
},
]
);
expect(md).toContain(
"- executor_id: integer(int64) - 退会処理を実行するユーザーのID 例: `1`"
);
});
});
describe("buildEndpointsMarkdown - anyOf の解決", () => {
it("同じスカラー型の union をその型として出力する", () => {
const md = buildEndpointsMarkdown(
{
paths: {},
components: {
schemas: {
Prefecture: { type: "string", enum: ["東京都"] },
Response: {
type: "object",
required: ["workplace_prefecture"],
properties: {
workplace_prefecture: {
description: "仕事場所の都道府県",
anyOf: [
{ $ref: "#/components/schemas/Prefecture" },
{ type: "string", enum: [""] },
],
},
},
},
},
},
},
[
{
path: "/kaigyo_application",
operations: [
{
method: "GET",
responses: {
"200": {
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Response" },
},
},
},
},
},
],
},
]
);
expect(md).toContain(
"- workplace_prefecture*: string - 仕事場所の都道府県"
);
expect(md).not.toContain("- workplace_prefecture*: object");
});
it("異なる型を持つ union はスカラー型として出力しない", () => {
const md = buildEndpointsMarkdown(
{
paths: {},
components: {
schemas: {
Response: {
type: "object",
properties: {
value: {
anyOf: [{ type: "string" }, { type: "integer" }],
},
},
},
},
},
},
[
{
path: "/mixed-union",
operations: [
{
method: "GET",
responses: {
"200": {
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Response" },
},
},
},
},
},
],
},
]
);
expect(md).toContain("- value: object");
});
});
describe("buildEndpointsMarkdown - 法人税申告APIのレスポンス", () => {
const responseSchema: OpenAPISchema = {
paths: {},
components: {
schemas: {
SheetResponse: {
type: "object",
properties: {
data: {
type: "object",
properties: {
tax_data: {
type: "object",
properties: {
sheet_key: { type: "string", description: "帳票キー" },
},
},
},
},
},
},
},
},
};
const responseEndpoints: PathData[] = [
{
path: "/hub/tax_return/corporate/sheet/national/{tax_return_id}/{sheet_key}",
operations: [
{
method: "GET",
responses: {
"200": {
description: "帳票データ",
content: {
"application/xml": {},
"application/json": {
schema: { $ref: "#/components/schemas/SheetResponse" },
},
},
},
},
},
],
},
];
it("通常APIはレスポンスをトップレベルだけに絞る", () => {
const md = buildEndpointsMarkdown(responseSchema, responseEndpoints);
expect(md).toContain("- data: object");
expect(md).not.toContain("- tax_data: object");
expect(md).not.toContain("- sheet_key: string");
});
it("法人税申告APIは帳票項目の対応に必要な3階層目まで出力する", () => {
const md = buildEndpointsMarkdown(
responseSchema,
responseEndpoints,
"tax-return-api"
);
expect(md).toContain("レスポンス形式: `application/xml`(推奨)");
expect(md).toContain("- tax_data: object");
expect(md).toContain("- sheet_key: string - 帳票キー");
});
});
describe("countOperations", () => {
it("パス × HTTP メソッドの数を数える", () => {
const target: OpenAPISchema = {
paths: {
"/deals": { get: {}, post: {} },
"/deals/{id}": { get: {}, put: {}, delete: {} },
"/deals/{id}/renews": { patch: {} },
},
};
expect(countOperations(target)).toBe(6);
});
it("HTTP メソッド以外のキーは数えない", () => {
const target: OpenAPISchema = {
paths: {
// parameters はパス共通のパラメータ定義であって操作ではない
"/deals": { get: {}, parameters: {}, servers: {} },
},
};
expect(countOperations(target)).toBe(1);
});
it("paths が空でも 0 を返す", () => {
expect(countOperations({ paths: {} })).toBe(0);
});
});
describe("replaceOperationCount", () => {
const readme = [
"# freee-mcp",
"",
"- 対応操作数: <!-- API-STATS-TOTAL-START -->485<!-- API-STATS-TOTAL-END --> 操作",
"",
].join("\n");
it("マーカー間の数値だけを差し替える", () => {
expect(replaceOperationCount(readme, 500)).toBe(
readme.replace("485", "500")
);
});
it("同じ数値なら入力と等しい文字列を返す", () => {
expect(replaceOperationCount(readme, 485)).toBe(readme);
});
it("マーカーが無ければ null を返す", () => {
expect(replaceOperationCount("# freee-mcp\n", 485)).toBeNull();
});
it("開始マーカーだけで終了マーカーが無ければ null を返す", () => {
expect(
replaceOperationCount("<!-- API-STATS-TOTAL-START -->485\n", 485)
).toBeNull();
});
});