mirror of
https://github.com/freee/freee-mcp.git
synced 2026-09-19 02:18:01 +08:00
70ed5f97b0
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>
439 lines
12 KiB
TypeScript
439 lines
12 KiB
TypeScript
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([]);
|
||
});
|
||
});
|