Files
freee__freee-mcp/scripts/generate-references.test.ts
Shohei Ikeda 70ed5f97b0 feat(mcponly): 開業・人事評価を service として分離する
mcp-only なエンドポイントはドメイン横断で 1 ファイル (mcponly.yml) に集約されて
配信される。freee-mcp 側は survey service がその全体を掴んでいたため、
開業 (/hub/launch/) と人事評価 (/hub/employee_evaluation/) のエンドポイントが
service: "survey" として扱われていた。

前 commit で入れた pathPrefix を使い、3 ドメインが mcponly ソースを分け合う形にする。

- SERVICE_METADATA に launch / employee_evaluation を追加し、survey を含む 3 つに
  pathPrefix を設定する。survey の表示名を他サービスに合わせて freeeサーベイ API にする
- serviceSchema / SERVICE_HINT / SERVER_INSTRUCTIONS に 2 サービスを追加する
- generate-references の API_CONFIGS に tagServices を追加する。1 ファイルに複数
  service が同居する場合、prefix はエントリに 1 つしか持てずファイル名も INDEX の
  グルーピングも 1 service に潰れるため、タグごとに service を明示する。
  タグ名からはドメインの区切りを判定できないので導出はしない。未定義のタグは
  生成時にエラーにする
- tagServices の値が SERVICE_LABELS に存在することを検証するテストを追加する。
  綴りが違うとリファレンスは生成されるのに INDEX から黙って漏れるため
- tag-mappings の englishName から、service と重複するドメイン名を落とす
- リファレンスを再生成し、survey-launch-kaigyo-application.md と
  survey-employee-evaluation-evaluation-results.md をサービス名に沿ってリネームする
- 開業のレシピを追加する。PATCH は部分更新だが family_employees だけは
  全件置き換えになるため、取得してから全件送る手順を明記する

FREEE_API_BASE_URL_SURVEY が開業・人事評価の向き先まで変えてしまう問題も解消し、
サービスごとに独立して指定できるようになる。

BREAKING: service: "survey" で開業・人事評価のパスを呼ぶとパス検証エラーになる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:34:19 +09:00

439 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,
findServicesMissingFromIndex,
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();
});
});
describe("API_CONFIGS と SERVICE_LABELS の整合", () => {
it("索引対象の service がすべて SERVICE_LABELS に存在する", () => {
expect(findServicesMissingFromIndex()).toEqual([]);
});
});