From d55423f4bd2251d2ed323de205c666e32123ca01 Mon Sep 17 00:00:00 2001 From: KazumaOhashi Date: Thu, 13 Mar 2025 18:16:50 +0900 Subject: [PATCH 1/4] feat: Add configuration commands for managing LIFF CLI settings --- src/channel/stores/channels.ts | 75 ++++++++++++++++++++++++++++++++++ src/config/commands/get.ts | 48 ++++++++++++++++++++++ src/config/commands/index.ts | 14 +++++++ src/config/commands/list.ts | 49 ++++++++++++++++++++++ src/config/commands/set.ts | 57 ++++++++++++++++++++++++++ src/config/constants.ts | 15 +++++++ src/setup.test.ts | 1 + src/setup.ts | 2 + 8 files changed, 261 insertions(+) create mode 100644 src/config/commands/get.ts create mode 100644 src/config/commands/index.ts create mode 100644 src/config/commands/list.ts create mode 100644 src/config/commands/set.ts create mode 100644 src/config/constants.ts diff --git a/src/channel/stores/channels.ts b/src/channel/stores/channels.ts index 5591a85..a3b61ac 100644 --- a/src/channel/stores/channels.ts +++ b/src/channel/stores/channels.ts @@ -1,6 +1,8 @@ import Conf from "conf"; import { promises as fs } from "fs"; +import { BASE_URL_CONFIG } from "../../config/constants.js"; + const packageJson: { name: string; version: string; @@ -41,6 +43,10 @@ export type ChannelInfo = { accessToken: string; expiresIn: number; issuedAt: number; + baseUrl?: { + api?: string; + liff?: string; + }; }; type ChannelConfig = { @@ -66,11 +72,14 @@ export const upsertChannel = ( issuedAt: number, ): ChannelInfo => { const channels = store.get("channels") || {}; + const existingChannel = channels[channelId]; channels[channelId] = { secret: channelSecret, accessToken, expiresIn, issuedAt, + // Preserve existing endpoint settings if they exist + ...(existingChannel?.baseUrl ? { baseUrl: existingChannel.baseUrl } : {}), }; store.set("channels", channels); @@ -85,6 +94,15 @@ export const getChannel = (channelId: string): ChannelInfo | undefined => { return channels[channelId]; }; +export const setChannel = ( + channelId: string, + channelInfo: ChannelInfo, +): void => { + const channels = store.get("channels") || {}; + channels[channelId] = channelInfo; + store.set("channels", channels); +}; + export const setCurrentChannel = (channelId: string): void => { store.set("currentChannelId", channelId); }; @@ -92,3 +110,60 @@ export const setCurrentChannel = (channelId: string): void => { export const getCurrentChannelId = (): string | undefined => { return store.get("currentChannelId"); }; + +export const getApiBaseUrl = (channelId: string): string => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not found.`); + } + + return channel.baseUrl?.api || BASE_URL_CONFIG.api.defaultBaseUrl; +}; + +export const getLiffBaseUrl = (channelId: string): string => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not found.`); + } + + return channel.baseUrl?.liff || BASE_URL_CONFIG.liff.defaultBaseUrl; +}; + +export const setApiBaseUrl = (channelId: string, apiBaseUrl: string): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + const channels = store.get("channels") || {}; + channels[channelId] = { + ...channel, + baseUrl: { + ...channel.baseUrl, + api: apiBaseUrl, + }, + }; + + store.set("channels", channels); +}; + +export const setLiffBaseUrl = ( + channelId: string, + liffBaseUrl: string, +): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + const channels = store.get("channels") || {}; + channels[channelId] = { + ...channel, + baseUrl: { + ...channel.baseUrl, + liff: liffBaseUrl, + }, + }; + + store.set("channels", channels); +}; diff --git a/src/config/commands/get.ts b/src/config/commands/get.ts new file mode 100644 index 0000000..0423951 --- /dev/null +++ b/src/config/commands/get.ts @@ -0,0 +1,48 @@ +import { createCommand } from "commander"; +import { + getCurrentChannelId, + getApiBaseUrl, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; +import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; + +const getAction = (key: string, { channelId }: { channelId?: string }) => { + const resolvedChannelId = channelId || getCurrentChannelId(); + if (!resolvedChannelId) { + throw new Error( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + } + + switch (key) { + case BASE_URL_CONFIG.api.configKey: + console.info(getApiBaseUrl(resolvedChannelId)); + break; + case BASE_URL_CONFIG.liff.configKey: + console.info(getLiffBaseUrl(resolvedChannelId)); + break; + default: + throw new Error(`Unknown config key: ${key}`); + } +}; + +export const makeGetCommand = () => { + const get = createCommand("get"); + get + .description("Get a configuration value") + .argument("", `Configuration key (${VALID_CONFIG_KEYS.join(", ")})`) + .option( + "-c, --channel-id [channelId]", + "Channel ID to get config for (defaults to current channel)", + ) + .action((key, options) => { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Error: Unknown config key: ${key}`); + console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`); + process.exit(1); + } + getAction(key, options); + }); + + return get; +}; diff --git a/src/config/commands/index.ts b/src/config/commands/index.ts new file mode 100644 index 0000000..814e1c1 --- /dev/null +++ b/src/config/commands/index.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; + +import { makeGetCommand } from "./get.js"; +import { makeListCommand } from "./list.js"; +import { makeSetCommand } from "./set.js"; + +export const installConfigCommands = (program: Command) => { + const config = program.command("config"); + config.description("Manage LIFF CLI configuration"); + + config.addCommand(makeGetCommand()); + config.addCommand(makeListCommand()); + config.addCommand(makeSetCommand()); +}; diff --git a/src/config/commands/list.ts b/src/config/commands/list.ts new file mode 100644 index 0000000..efaf713 --- /dev/null +++ b/src/config/commands/list.ts @@ -0,0 +1,49 @@ +import { createCommand } from "commander"; +import { + getChannel, + getCurrentChannelId, + getApiBaseUrl, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; +import { BASE_URL_CONFIG } from "../constants.js"; + +const listAction = ({ channelId }: { channelId?: string }) => { + const resolvedChannelId = channelId || getCurrentChannelId(); + if (!resolvedChannelId) { + throw new Error( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + } + + const channel = getChannel(resolvedChannelId); + if (!channel) { + throw new Error(`Channel ${resolvedChannelId} is not found.`); + } + + console.info(`Configuration for channel ${resolvedChannelId}:`); + + const apiBaseUrl = getApiBaseUrl(resolvedChannelId); + const liffBaseUrl = getLiffBaseUrl(resolvedChannelId); + + // api-base-url = https://api.line.me (default) + console.info( + `${BASE_URL_CONFIG.api.configKey} = ${apiBaseUrl} ${apiBaseUrl === BASE_URL_CONFIG.api.defaultBaseUrl ? "(default)" : ""}`, + ); + // liff-base-url = https://liff.line.me (default) + console.info( + `${BASE_URL_CONFIG.liff.configKey} = ${liffBaseUrl} ${liffBaseUrl === BASE_URL_CONFIG.liff.defaultBaseUrl ? "(default)" : ""}`, + ); +}; + +export const makeListCommand = () => { + const list = createCommand("list"); + list + .description("List all configuration values") + .option( + "-c, --channel-id [channelId]", + "Channel ID to list config for (defaults to current channel)", + ) + .action(listAction); + + return list; +}; diff --git a/src/config/commands/set.ts b/src/config/commands/set.ts new file mode 100644 index 0000000..dab79c3 --- /dev/null +++ b/src/config/commands/set.ts @@ -0,0 +1,57 @@ +import { createCommand } from "commander"; +import { + getCurrentChannelId, + setApiBaseUrl, + setLiffBaseUrl, +} from "../../channel/stores/channels.js"; +import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; + +type ConfigValue = { + key: string; + value: string; + channelId?: string; +}; + +const setAction = ({ key, value, channelId }: ConfigValue) => { + const resolvedChannelId = channelId || getCurrentChannelId(); + if (!resolvedChannelId) { + throw new Error( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + } + + switch (key) { + case BASE_URL_CONFIG.api.configKey: + setApiBaseUrl(resolvedChannelId, value); + break; + case BASE_URL_CONFIG.liff.configKey: + setLiffBaseUrl(resolvedChannelId, value); + break; + default: + throw new Error(`Unknown config key: ${key}`); + } + + console.info(`Successfully set ${key} to ${value}`); +}; + +export const makeSetCommand = () => { + const set = createCommand("set"); + set + .description("Set a configuration value") + .argument("", `Configuration key (${VALID_CONFIG_KEYS.join(", ")})`) + .argument("", "Value to set") + .option( + "-c, --channel-id [channelId]", + "Channel ID to set config for (defaults to current channel)", + ) + .action((key, value, options) => { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Error: Unknown config key: ${key}`); + console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`); + process.exit(1); + } + setAction({ key, value, channelId: options.channelId }); + }); + + return set; +}; diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..35e8d61 --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,15 @@ +export const BASE_URL_CONFIG = { + api: { + defaultBaseUrl: "https://api.line.me", + configKey: "api-base-url", + }, + liff: { + defaultBaseUrl: "https://liff.line.me", + configKey: "liff-base-url", + }, +} as const; + +export const VALID_CONFIG_KEYS = [ + BASE_URL_CONFIG.api.configKey, + BASE_URL_CONFIG.liff.configKey, +] as const; diff --git a/src/setup.test.ts b/src/setup.test.ts index a091c91..4081bdf 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -26,6 +26,7 @@ Commands: channel Manage LIFF channels app Manage LIFF apps serve [options] Manage HTTPS dev server + config Manage LIFF CLI configuration help [command] display help for command `); }); diff --git a/src/setup.ts b/src/setup.ts index 44925d5..7462fa3 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -2,11 +2,13 @@ import { Command } from "commander"; import { installChannelCommands } from "./channel/commands/index.js"; import { installAppCommands } from "./app/commands/index.js"; import { installServeCommands } from "./serve/commands/index.js"; +import { installConfigCommands } from "./config/commands/index.js"; export const setupCLI = (program: Command) => { installChannelCommands(program); installAppCommands(program); installServeCommands(program); + installConfigCommands(program); // TODO .version? return { run: (argv = process.argv) => { From 3baf3002401ebbbe9732351f5d843e7b59e40ab1 Mon Sep 17 00:00:00 2001 From: KazumaOhashi Date: Thu, 13 Mar 2025 18:19:15 +0900 Subject: [PATCH 2/4] feat: Update LIFF commands to use dynamic base URLs and improve error handling for channel IDs --- src/app/commands/create.ts | 18 ++++++++++++++---- src/app/commands/delete.ts | 20 ++++++++++++++++---- src/app/commands/list.ts | 20 ++++++++++++++++---- src/app/commands/update.ts | 19 +++++++++++++++---- src/channel/renewAccessToken.ts | 9 +++++++-- src/serve/serveAction.ts | 24 +++++++++++++++++------- 6 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/app/commands/create.ts b/src/app/commands/create.ts index 9e240a0..a8e34f7 100644 --- a/src/app/commands/create.ts +++ b/src/app/commands/create.ts @@ -1,6 +1,10 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { + getCurrentChannelId, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; const createAction = async (options: { channelId?: string; @@ -8,16 +12,22 @@ const createAction = async (options: { endpointUrl: string; viewType: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const channelId = options?.channelId || getCurrentChannelId(); + if (!channelId) { + throw new Error("Channel ID is required."); + } + + const liffBaseUrl = getLiffBaseUrl(channelId); const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); const { liffId } = await client.addApp({ view: { diff --git a/src/app/commands/delete.ts b/src/app/commands/delete.ts index ee97eb0..3af9bc7 100644 --- a/src/app/commands/delete.ts +++ b/src/app/commands/delete.ts @@ -2,13 +2,17 @@ import { Command } from "commander"; import { resolveChannel } from "../../channel/resolveChannel.js"; import { LiffApiClient } from "../../api/liff.js"; import inquirer from "inquirer"; +import { + getCurrentChannelId, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; const deleteAction = async (options: { channelId?: string; liffId: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); @@ -24,10 +28,18 @@ const deleteAction = async (options: { ]); if (!confirmDelete) return; + const channelId = options?.channelId || getCurrentChannelId(); + if (!channelId) { + throw new Error("Channel ID is required."); + } + + const liffBaseUrl = getLiffBaseUrl(channelId); + const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); + console.info(`Deleting LIFF app...`); await client.deleteApp(options.liffId); diff --git a/src/app/commands/list.ts b/src/app/commands/list.ts index 8240965..386a4d9 100644 --- a/src/app/commands/list.ts +++ b/src/app/commands/list.ts @@ -1,19 +1,31 @@ import { Command } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { + getCurrentChannelId, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; const listAction = async (options: { channelId?: string }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const channelId = options?.channelId || getCurrentChannelId(); + if (!channelId) { + throw new Error("Channel ID is required."); + } + + const liffBaseUrl = getLiffBaseUrl(channelId); + const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); + const { apps } = await client.fetchApps(); console.info("LIFF apps:"); diff --git a/src/app/commands/update.ts b/src/app/commands/update.ts index 320f061..efb5708 100644 --- a/src/app/commands/update.ts +++ b/src/app/commands/update.ts @@ -1,6 +1,10 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { + getCurrentChannelId, + getLiffBaseUrl, +} from "../../channel/stores/channels.js"; const updateAction = async (options: { liffId: string; @@ -9,16 +13,23 @@ const updateAction = async (options: { endpointUrl?: string; viewType?: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const channelId = options?.channelId || getCurrentChannelId(); + if (!channelId) { + throw new Error("Channel ID is required."); + } + + const liffBaseUrl = getLiffBaseUrl(channelId); + const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); await client.updateApp(options.liffId, { view: { diff --git a/src/channel/renewAccessToken.ts b/src/channel/renewAccessToken.ts index b047cf6..6c83906 100644 --- a/src/channel/renewAccessToken.ts +++ b/src/channel/renewAccessToken.ts @@ -1,13 +1,18 @@ import { AuthApiClient } from "../api/auth.js"; -import { ChannelInfo, upsertChannel } from "./stores/channels.js"; +import { + ChannelInfo, + getApiBaseUrl, + upsertChannel, +} from "./stores/channels.js"; export const renewAccessToken = async ( channelId: string, channelSecret: string, issuedAt: number, ): Promise => { + const apiBaseUrl = getApiBaseUrl(channelId); const client = new AuthApiClient({ - baseUrl: "https://api.line.me", + baseUrl: apiBaseUrl, }); const res = await client.fetchStatelessChannelAccessToken({ channelId: channelId, diff --git a/src/serve/serveAction.ts b/src/serve/serveAction.ts index a88732a..1fb8a73 100644 --- a/src/serve/serveAction.ts +++ b/src/serve/serveAction.ts @@ -1,7 +1,10 @@ import { spawn } from "node:child_process"; import { LiffApiClient } from "../api/liff.js"; import { resolveChannel } from "../channel/resolveChannel.js"; -import { getCurrentChannelId } from "../channel/stores/channels.js"; +import { + getCurrentChannelId, + getLiffBaseUrl, +} from "../channel/stores/channels.js"; import { ProxyInterface } from "./proxy/proxy-interface.js"; import resolveEndpointUrl from "./resolveEndpointUrl.js"; import pc from "picocolors"; @@ -17,9 +20,15 @@ export const serveAction = async ( }, proxy: ProxyInterface, ) => { - const accessToken = (await resolveChannel(getCurrentChannelId())) - ?.accessToken; - if (!accessToken) { + const currentChannelId = getCurrentChannelId(); + if (!currentChannelId) { + throw new Error(`Current channel not set. + Please set the current channel first. + `); + } + + const channelInfo = await resolveChannel(currentChannelId); + if (!channelInfo) { throw new Error(`Access token not found. Please set the current channel first. `); @@ -48,12 +57,13 @@ export const serveAction = async ( } const httpsUrl = await proxy.connect(endpointUrl); - const liffUrl = new URL("https://liff.line.me/"); + const liffBaseUrl = getLiffBaseUrl(currentChannelId); + const liffUrl = new URL(liffBaseUrl); liffUrl.pathname = options.liffId; const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); await client.updateApp(options.liffId, { view: { url: httpsUrl.toString() }, From b743e93811b07e6beeada86e981c01978c9a6e79 Mon Sep 17 00:00:00 2001 From: KazumaOhashi Date: Fri, 14 Mar 2025 15:41:10 +0900 Subject: [PATCH 3/4] feat: Implement base URL management functions and tests for API and LIFF URLs --- src/channel/baseUrl.test.ts | 183 +++++++++++++++++++++++++++++++++ src/channel/baseUrl.ts | 52 ++++++++++ src/channel/stores/channels.ts | 58 ----------- 3 files changed, 235 insertions(+), 58 deletions(-) create mode 100644 src/channel/baseUrl.test.ts create mode 100644 src/channel/baseUrl.ts diff --git a/src/channel/baseUrl.test.ts b/src/channel/baseUrl.test.ts new file mode 100644 index 0000000..f9db9f7 --- /dev/null +++ b/src/channel/baseUrl.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getApiBaseUrl, getLiffBaseUrl, setApiBaseUrl, setLiffBaseUrl } from "./baseUrl.js"; +import { resolveChannel } from "./resolveChannel.js"; +import { getChannel, setChannel } from "./stores/channels.js"; + +vi.mock("./resolveChannel.js"); +vi.mock("./stores/channels.js"); + +describe("baseUrl", () => { + const mockChannelId = "123"; + const mockApiBaseUrl = "https://api.example.com"; + const mockLiffBaseUrl = "https://liff.example.com"; + const mockChannel = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + baseUrl: { + api: mockApiBaseUrl, + liff: mockLiffBaseUrl, + }, + }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getApiBaseUrl", () => { + it("should return the api base URL from channel info", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(mockChannel); + + const result = await getApiBaseUrl(mockChannelId); + + expect(resolveChannel).toHaveBeenCalledWith(mockChannelId); + expect(result).toBe(mockApiBaseUrl); + }); + + it("should use default API base URL if not set in channel", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce({ + ...mockChannel, + baseUrl: {} // No API base URL set + }); + + const result = await getApiBaseUrl(mockChannelId); + + expect(result).toBe("https://api.line.me"); // Default from BASE_URL_CONFIG + }); + + it("should throw an error if channel is not found", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(undefined); + + await expect(getApiBaseUrl(mockChannelId)).rejects.toThrow("Channel not found."); + }); + }); + + describe("getLiffBaseUrl", () => { + it("should return the liff base URL from channel info", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(mockChannel); + + const result = await getLiffBaseUrl(mockChannelId); + + expect(resolveChannel).toHaveBeenCalledWith(mockChannelId); + expect(result).toBe(mockLiffBaseUrl); + }); + + it("should use default LIFF base URL if not set in channel", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce({ + ...mockChannel, + baseUrl: {} // No LIFF base URL set + }); + + const result = await getLiffBaseUrl(mockChannelId); + + expect(result).toBe("https://liff.line.me"); // Default from BASE_URL_CONFIG + }); + + it("should throw an error if channel is not found", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(undefined); + + await expect(getLiffBaseUrl(mockChannelId)).rejects.toThrow("Channel not found."); + }); + }); + + describe("setApiBaseUrl", () => { + it("should update the API base URL of a channel", () => { + const newApiBaseUrl = "https://new-api.example.com"; + vi.mocked(getChannel).mockReturnValueOnce(mockChannel); + + setApiBaseUrl(mockChannelId, newApiBaseUrl); + + expect(getChannel).toHaveBeenCalledWith(mockChannelId); + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...mockChannel, + baseUrl: { + ...mockChannel.baseUrl, + api: newApiBaseUrl, + }, + }); + }); + + it("should throw an error if channel is not found", () => { + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + expect(() => setApiBaseUrl(mockChannelId, "https://api.example.com")).toThrow( + `Channel ${mockChannelId} is not added yet.` + ); + expect(setChannel).not.toHaveBeenCalled(); + }); + + it("should handle channel without baseUrl property", () => { + const channelWithoutBaseUrl = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + }; + const newApiBaseUrl = "https://new-api.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce(channelWithoutBaseUrl); + + setApiBaseUrl(mockChannelId, newApiBaseUrl); + + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...channelWithoutBaseUrl, + baseUrl: { + api: newApiBaseUrl, + }, + }); + }); + }); + + describe("setLiffBaseUrl", () => { + it("should update the LIFF base URL of a channel", () => { + const newLiffBaseUrl = "https://new-liff.example.com"; + vi.mocked(getChannel).mockReturnValueOnce(mockChannel); + + setLiffBaseUrl(mockChannelId, newLiffBaseUrl); + + expect(getChannel).toHaveBeenCalledWith(mockChannelId); + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...mockChannel, + baseUrl: { + ...mockChannel.baseUrl, + liff: newLiffBaseUrl, + }, + }); + }); + + it("should throw an error if channel is not found", () => { + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + expect(() => setLiffBaseUrl(mockChannelId, "https://liff.example.com")).toThrow( + `Channel ${mockChannelId} is not added yet.` + ); + expect(setChannel).not.toHaveBeenCalled(); + }); + + it("should handle channel without baseUrl property", () => { + const channelWithoutBaseUrl = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + }; + const newLiffBaseUrl = "https://new-liff.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce(channelWithoutBaseUrl); + + setLiffBaseUrl(mockChannelId, newLiffBaseUrl); + + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...channelWithoutBaseUrl, + baseUrl: { + liff: newLiffBaseUrl, + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/channel/baseUrl.ts b/src/channel/baseUrl.ts new file mode 100644 index 0000000..509c0ab --- /dev/null +++ b/src/channel/baseUrl.ts @@ -0,0 +1,52 @@ +import { BASE_URL_CONFIG } from "../config/constants.js"; +import { resolveChannel } from "./resolveChannel.js"; +import { getChannel, setChannel } from "./stores/channels.js"; + +export const getApiBaseUrl = async (channelId?: string): Promise => { + const channelInfo = await resolveChannel(channelId); + if (!channelInfo) { + throw new Error(`Channel not found.`); + } + return channelInfo.baseUrl?.api || BASE_URL_CONFIG.api.defaultBaseUrl; +}; + +export const getLiffBaseUrl = async (channelId?: string): Promise => { + const channelInfo = await resolveChannel(channelId); + if (!channelInfo) { + throw new Error(`Channel not found.`); + } + return channelInfo.baseUrl?.liff || BASE_URL_CONFIG.liff.defaultBaseUrl; +}; + +export const setApiBaseUrl = (channelId: string, apiBaseUrl: string): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + setChannel(channelId, { + ...channel, + baseUrl: { + ...channel.baseUrl, + api: apiBaseUrl, + }, + }); +}; + +export const setLiffBaseUrl = ( + channelId: string, + liffBaseUrl: string, +): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + setChannel(channelId, { + ...channel, + baseUrl: { + ...channel.baseUrl, + liff: liffBaseUrl, + }, + }); +}; diff --git a/src/channel/stores/channels.ts b/src/channel/stores/channels.ts index a3b61ac..7fc55e4 100644 --- a/src/channel/stores/channels.ts +++ b/src/channel/stores/channels.ts @@ -1,7 +1,6 @@ import Conf from "conf"; import { promises as fs } from "fs"; -import { BASE_URL_CONFIG } from "../../config/constants.js"; const packageJson: { name: string; @@ -110,60 +109,3 @@ export const setCurrentChannel = (channelId: string): void => { export const getCurrentChannelId = (): string | undefined => { return store.get("currentChannelId"); }; - -export const getApiBaseUrl = (channelId: string): string => { - const channel = getChannel(channelId); - if (!channel) { - throw new Error(`Channel ${channelId} is not found.`); - } - - return channel.baseUrl?.api || BASE_URL_CONFIG.api.defaultBaseUrl; -}; - -export const getLiffBaseUrl = (channelId: string): string => { - const channel = getChannel(channelId); - if (!channel) { - throw new Error(`Channel ${channelId} is not found.`); - } - - return channel.baseUrl?.liff || BASE_URL_CONFIG.liff.defaultBaseUrl; -}; - -export const setApiBaseUrl = (channelId: string, apiBaseUrl: string): void => { - const channel = getChannel(channelId); - if (!channel) { - throw new Error(`Channel ${channelId} is not added yet.`); - } - - const channels = store.get("channels") || {}; - channels[channelId] = { - ...channel, - baseUrl: { - ...channel.baseUrl, - api: apiBaseUrl, - }, - }; - - store.set("channels", channels); -}; - -export const setLiffBaseUrl = ( - channelId: string, - liffBaseUrl: string, -): void => { - const channel = getChannel(channelId); - if (!channel) { - throw new Error(`Channel ${channelId} is not added yet.`); - } - - const channels = store.get("channels") || {}; - channels[channelId] = { - ...channel, - baseUrl: { - ...channel.baseUrl, - liff: liffBaseUrl, - }, - }; - - store.set("channels", channels); -}; From 3c2e677d90885904aae0c303680a0e2f85756550 Mon Sep 17 00:00:00 2001 From: KazumaOhashi Date: Fri, 14 Mar 2025 15:42:35 +0900 Subject: [PATCH 4/4] feat: Update commands to use async base URL retrieval and improve test cases --- src/app/commands/create.test.ts | 5 + src/app/commands/create.ts | 13 +-- src/app/commands/delete.test.ts | 3 + src/app/commands/delete.ts | 13 +-- src/app/commands/list.test.ts | 5 + src/app/commands/list.ts | 13 +-- src/app/commands/update.test.ts | 5 + src/app/commands/update.ts | 13 +-- src/channel/renewAccessToken.test.ts | 3 + src/channel/renewAccessToken.ts | 9 +- src/config/commands/get.test.ts | 109 ++++++++++++++++++ src/config/commands/get.ts | 23 +--- src/config/commands/index.test.ts | 38 +++++++ src/config/commands/list.test.ts | 162 +++++++++++++++++++++++++++ src/config/commands/list.ts | 9 +- src/config/commands/set.test.ts | 100 +++++++++++++++++ src/config/commands/set.ts | 7 +- src/serve/serveAction.test.ts | 12 +- src/serve/serveAction.ts | 8 +- 19 files changed, 467 insertions(+), 83 deletions(-) create mode 100644 src/config/commands/get.test.ts create mode 100644 src/config/commands/index.test.ts create mode 100644 src/config/commands/list.test.ts create mode 100644 src/config/commands/set.test.ts diff --git a/src/app/commands/create.test.ts b/src/app/commands/create.test.ts index 2a51cd3..7c2c30c 100644 --- a/src/app/commands/create.test.ts +++ b/src/app/commands/create.test.ts @@ -44,6 +44,11 @@ describe("makeCreateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockAddApp).mockResolvedValueOnce({ liffId: "12345" }); diff --git a/src/app/commands/create.ts b/src/app/commands/create.ts index a8e34f7..7764320 100644 --- a/src/app/commands/create.ts +++ b/src/app/commands/create.ts @@ -1,10 +1,7 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; -import { - getCurrentChannelId, - getLiffBaseUrl, -} from "../../channel/stores/channels.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const createAction = async (options: { channelId?: string; @@ -18,13 +15,7 @@ const createAction = async (options: { Please provide a valid channel ID or set the current channel first. `); } - - const channelId = options?.channelId || getCurrentChannelId(); - if (!channelId) { - throw new Error("Channel ID is required."); - } - - const liffBaseUrl = getLiffBaseUrl(channelId); + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ token: channelInfo.accessToken, baseUrl: liffBaseUrl, diff --git a/src/app/commands/delete.test.ts b/src/app/commands/delete.test.ts index 08ebc66..bd97685 100644 --- a/src/app/commands/delete.test.ts +++ b/src/app/commands/delete.test.ts @@ -12,6 +12,7 @@ import inquire from "inquirer"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; import { makeDeleteCommand } from "./delete.js"; +import { getApiBaseUrl } from "../../channel/baseUrl.js"; vi.mock("inquirer"); @@ -24,6 +25,7 @@ vi.mock("../../api/liff.js", () => { }; }); vi.mock("../../channel/resolveChannel.js"); +vi.mock("../../channel/baseUrl.js"); describe("makeDeleteCommand", () => { let mockConsoleInfo: MockInstance< @@ -44,6 +46,7 @@ describe("makeDeleteCommand", () => { }); it("should delete a LIFF app successfully", async () => { + vi.mocked(getApiBaseUrl).mockResolvedValueOnce("https://api.line.me"); vi.mocked(resolveChannel).mockResolvedValueOnce({ accessToken: "token", expiresIn: 3600, diff --git a/src/app/commands/delete.ts b/src/app/commands/delete.ts index 3af9bc7..111eed1 100644 --- a/src/app/commands/delete.ts +++ b/src/app/commands/delete.ts @@ -2,10 +2,7 @@ import { Command } from "commander"; import { resolveChannel } from "../../channel/resolveChannel.js"; import { LiffApiClient } from "../../api/liff.js"; import inquirer from "inquirer"; -import { - getCurrentChannelId, - getLiffBaseUrl, -} from "../../channel/stores/channels.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const deleteAction = async (options: { channelId?: string; @@ -17,6 +14,7 @@ const deleteAction = async (options: { Please provide a valid channel ID or set the current channel first. `); } + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const { confirmDelete } = await inquirer.prompt<{ confirmDelete: boolean }>([ { @@ -28,13 +26,6 @@ const deleteAction = async (options: { ]); if (!confirmDelete) return; - const channelId = options?.channelId || getCurrentChannelId(); - if (!channelId) { - throw new Error("Channel ID is required."); - } - - const liffBaseUrl = getLiffBaseUrl(channelId); - const client = new LiffApiClient({ token: channelInfo.accessToken, baseUrl: liffBaseUrl, diff --git a/src/app/commands/list.test.ts b/src/app/commands/list.test.ts index 7fde1bd..4a48eab 100644 --- a/src/app/commands/list.test.ts +++ b/src/app/commands/list.test.ts @@ -36,6 +36,11 @@ describe("makeListCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "valid_token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockFetchApps).mockResolvedValueOnce({ apps: [ diff --git a/src/app/commands/list.ts b/src/app/commands/list.ts index 386a4d9..637ac8c 100644 --- a/src/app/commands/list.ts +++ b/src/app/commands/list.ts @@ -1,10 +1,7 @@ import { Command } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; -import { - getCurrentChannelId, - getLiffBaseUrl, -} from "../../channel/stores/channels.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const listAction = async (options: { channelId?: string }) => { const channelInfo = await resolveChannel(options?.channelId); @@ -13,13 +10,7 @@ const listAction = async (options: { channelId?: string }) => { Please provide a valid channel ID or set the current channel first. `); } - - const channelId = options?.channelId || getCurrentChannelId(); - if (!channelId) { - throw new Error("Channel ID is required."); - } - - const liffBaseUrl = getLiffBaseUrl(channelId); + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ token: channelInfo.accessToken, diff --git a/src/app/commands/update.test.ts b/src/app/commands/update.test.ts index 7bb4f6a..71970cd 100644 --- a/src/app/commands/update.test.ts +++ b/src/app/commands/update.test.ts @@ -45,6 +45,11 @@ describe("makeUpdateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); diff --git a/src/app/commands/update.ts b/src/app/commands/update.ts index efb5708..05a5f61 100644 --- a/src/app/commands/update.ts +++ b/src/app/commands/update.ts @@ -1,10 +1,7 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; -import { - getCurrentChannelId, - getLiffBaseUrl, -} from "../../channel/stores/channels.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const updateAction = async (options: { liffId: string; @@ -19,13 +16,7 @@ const updateAction = async (options: { Please provide a valid channel ID or set the current channel first. `); } - - const channelId = options?.channelId || getCurrentChannelId(); - if (!channelId) { - throw new Error("Channel ID is required."); - } - - const liffBaseUrl = getLiffBaseUrl(channelId); + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ token: channelInfo.accessToken, diff --git a/src/channel/renewAccessToken.test.ts b/src/channel/renewAccessToken.test.ts index 2fdeee3..9920524 100644 --- a/src/channel/renewAccessToken.test.ts +++ b/src/channel/renewAccessToken.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { AuthApiClient } from "../api/auth.js"; import { upsertChannel } from "./stores/channels.js"; import { renewAccessToken } from "./renewAccessToken.js"; +import {getApiBaseUrl} from "./baseUrl.js" vi.mock("../api/auth.js", () => { const fn = vi.fn(); @@ -13,6 +14,7 @@ vi.mock("../api/auth.js", () => { }); vi.mock("./stores/channels.js"); +vi.mock("./baseUrl.js"); describe("renewAccessToken", () => { let mockFetchStatelessChannelAccessToken: AuthApiClient["fetchStatelessChannelAccessToken"]; @@ -36,6 +38,7 @@ describe("renewAccessToken", () => { const expiresIn = 3600; const issuedAt = Date.now(); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce("https://api.line.me"); vi.mocked(mockFetchStatelessChannelAccessToken).mockResolvedValue({ token_type: "Bearer", access_token: accessToken, diff --git a/src/channel/renewAccessToken.ts b/src/channel/renewAccessToken.ts index 6c83906..e9f3243 100644 --- a/src/channel/renewAccessToken.ts +++ b/src/channel/renewAccessToken.ts @@ -1,16 +1,13 @@ import { AuthApiClient } from "../api/auth.js"; -import { - ChannelInfo, - getApiBaseUrl, - upsertChannel, -} from "./stores/channels.js"; +import { getApiBaseUrl } from "./baseUrl.js"; +import { ChannelInfo, upsertChannel } from "./stores/channels.js"; export const renewAccessToken = async ( channelId: string, channelSecret: string, issuedAt: number, ): Promise => { - const apiBaseUrl = getApiBaseUrl(channelId); + const apiBaseUrl = await getApiBaseUrl(channelId); const client = new AuthApiClient({ baseUrl: apiBaseUrl, }); diff --git a/src/config/commands/get.test.ts b/src/config/commands/get.test.ts new file mode 100644 index 0000000..d5dce8f --- /dev/null +++ b/src/config/commands/get.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeGetCommand } from "./get.js"; +import { + getCurrentChannelId +} from "../../channel/stores/channels.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeGetCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.only("should get API base URL for the current channel", async () => { + const apiBaseUrl = "https://custom-api.example.com"; + + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + + const command = makeGetCommand(); + await command.parseAsync(["_", "get", "api-base-url"]); + + expect(getApiBaseUrl).toHaveBeenCalledWith(undefined); + expect(console.info).toHaveBeenCalledWith(apiBaseUrl); + }); + + it("should get LIFF base URL for a specific channel", async () => { + const channelId = "456"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeGetCommand(); + await command.parseAsync([ + "_", + "get", + "liff-base-url", + "--channel-id", + channelId, + ]); + + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + expect(console.info).toHaveBeenCalledWith(liffBaseUrl); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "api-base-url"]), + ).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error with invalid config key", async () => { + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "invalid-key"]), + ).rejects.toThrow("process.exit called with code 1"); + + expect(console.error).toHaveBeenCalledWith( + "Error: Unknown config key: invalid-key", + ); + expect(console.error).toHaveBeenCalledWith( + "Valid keys: api-base-url, liff-base-url", + ); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should propagate errors from base URL getter functions", async () => { + const channelId = "123"; + const errorMessage = "Failed to get API base URL"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getApiBaseUrl).mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "api-base-url"]), + ).rejects.toThrow(errorMessage); + + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + }); +}); diff --git a/src/config/commands/get.ts b/src/config/commands/get.ts index 0423951..5820cea 100644 --- a/src/config/commands/get.ts +++ b/src/config/commands/get.ts @@ -1,25 +1,14 @@ import { createCommand } from "commander"; -import { - getCurrentChannelId, - getApiBaseUrl, - getLiffBaseUrl, -} from "../../channel/stores/channels.js"; import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; -const getAction = (key: string, { channelId }: { channelId?: string }) => { - const resolvedChannelId = channelId || getCurrentChannelId(); - if (!resolvedChannelId) { - throw new Error( - "Channel ID is required. Either specify --channel-id or set the current channel.", - ); - } - +const getAction = async (key: string, { channelId }: { channelId?: string }) => { switch (key) { case BASE_URL_CONFIG.api.configKey: - console.info(getApiBaseUrl(resolvedChannelId)); + console.info(await getApiBaseUrl(channelId)); break; case BASE_URL_CONFIG.liff.configKey: - console.info(getLiffBaseUrl(resolvedChannelId)); + console.info(await getLiffBaseUrl(channelId)); break; default: throw new Error(`Unknown config key: ${key}`); @@ -35,13 +24,13 @@ export const makeGetCommand = () => { "-c, --channel-id [channelId]", "Channel ID to get config for (defaults to current channel)", ) - .action((key, options) => { + .action(async (key, options) => { if (!VALID_CONFIG_KEYS.includes(key)) { console.error(`Error: Unknown config key: ${key}`); console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`); process.exit(1); } - getAction(key, options); + await getAction(key, options); }); return get; diff --git a/src/config/commands/index.test.ts b/src/config/commands/index.test.ts new file mode 100644 index 0000000..fd0aa0f --- /dev/null +++ b/src/config/commands/index.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { Command } from "commander"; + +import { installConfigCommands } from "./index.js"; +import { makeGetCommand } from "./get.js"; +import { makeListCommand } from "./list.js"; +import { makeSetCommand } from "./set.js"; + +vi.mock("./get.js"); +vi.mock("./list.js"); +vi.mock("./set.js"); + +describe("installConfigCommands", () => { + it("should add subcommand to the config command", () => { + vi.mocked(makeGetCommand).mockReturnValueOnce(new Command("get")); + vi.mocked(makeListCommand).mockReturnValueOnce(new Command("list")); + vi.mocked(makeSetCommand).mockReturnValueOnce(new Command("set")); + + const program = new Command(); + installConfigCommands(program); + + const config = program.commands.find((cmd) => cmd.name() === "config"); + expect(config).toBeDefined(); + expect(config?.description()).toBe("Manage LIFF CLI configuration"); + + const getCommand = config?.commands.find((cmd) => cmd.name() === "get"); + expect(getCommand).toBeDefined(); + expect(makeGetCommand).toHaveBeenCalled(); + + const listCommand = config?.commands.find((cmd) => cmd.name() === "list"); + expect(listCommand).toBeDefined(); + expect(makeListCommand).toHaveBeenCalled(); + + const setCommand = config?.commands.find((cmd) => cmd.name() === "set"); + expect(setCommand).toBeDefined(); + expect(makeSetCommand).toHaveBeenCalled(); + }); +}); diff --git a/src/config/commands/list.test.ts b/src/config/commands/list.test.ts new file mode 100644 index 0000000..5416d60 --- /dev/null +++ b/src/config/commands/list.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeListCommand } from "./list.js"; +import { + getChannel, + getCurrentChannelId, +} from "../../channel/stores/channels.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeListCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should list all configuration values for the current channel", async () => { + const channelId = "123"; + const apiBaseUrl = "https://custom-api.example.com"; + const liffBaseUrl = "https://api.line.me"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + baseUrl: { + api: apiBaseUrl, + }, + }); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeListCommand(); + await command.parseAsync(["_", "list"]); + + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + + expect(console.info).toHaveBeenCalledTimes(3); + expect(console.info).toHaveBeenNthCalledWith( + 1, + `Configuration for channel ${channelId}:`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + `api-base-url = ${apiBaseUrl} `, + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + `liff-base-url = ${liffBaseUrl} `, + ); + }); + + it("should list all configuration values for a specific channel", async () => { + const channelId = "456"; + const apiBaseUrl = "https://api.line.me"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + baseUrl: { + liff: liffBaseUrl, + }, + }); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeListCommand(); + await command.parseAsync(["_", "list", "--channel-id", channelId]); + + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + + expect(console.info).toHaveBeenCalledTimes(3); + expect(console.info).toHaveBeenNthCalledWith( + 1, + `Configuration for channel ${channelId}:`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + `api-base-url = ${apiBaseUrl} (default)`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + `liff-base-url = ${liffBaseUrl} `, + ); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(getChannel).not.toHaveBeenCalled(); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error when channel does not exist", async () => { + const channelId = "nonexistent"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + `Channel ${channelId} is not found.`, + ); + + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should propagate errors from base URL getter functions", async () => { + const channelId = "123"; + const errorMessage = "Failed to get API base URL"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + }); + vi.mocked(getApiBaseUrl).mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + errorMessage, + ); + + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + }); +}); diff --git a/src/config/commands/list.ts b/src/config/commands/list.ts index efaf713..83d7595 100644 --- a/src/config/commands/list.ts +++ b/src/config/commands/list.ts @@ -2,12 +2,11 @@ import { createCommand } from "commander"; import { getChannel, getCurrentChannelId, - getApiBaseUrl, - getLiffBaseUrl, } from "../../channel/stores/channels.js"; import { BASE_URL_CONFIG } from "../constants.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; -const listAction = ({ channelId }: { channelId?: string }) => { +const listAction = async ({ channelId }: { channelId?: string }) => { const resolvedChannelId = channelId || getCurrentChannelId(); if (!resolvedChannelId) { throw new Error( @@ -22,8 +21,8 @@ const listAction = ({ channelId }: { channelId?: string }) => { console.info(`Configuration for channel ${resolvedChannelId}:`); - const apiBaseUrl = getApiBaseUrl(resolvedChannelId); - const liffBaseUrl = getLiffBaseUrl(resolvedChannelId); + const apiBaseUrl = await getApiBaseUrl(resolvedChannelId); + const liffBaseUrl = await getLiffBaseUrl(resolvedChannelId); // api-base-url = https://api.line.me (default) console.info( diff --git a/src/config/commands/set.test.ts b/src/config/commands/set.test.ts new file mode 100644 index 0000000..19a7262 --- /dev/null +++ b/src/config/commands/set.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeSetCommand } from "./set.js"; +import { + getCurrentChannelId, +} from "../../channel/stores/channels.js"; +import { setApiBaseUrl, setLiffBaseUrl } from "../../channel/baseUrl.js"; + + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeSetCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should set API base URL for the current channel", async () => { + const channelId = "123"; + const apiBaseUrl = "https://custom-api.example.com"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(setApiBaseUrl).mockImplementationOnce(() => {}); + + const command = makeSetCommand(); + await command.parseAsync(["_", "set", "api-base-url", apiBaseUrl]); + + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(setApiBaseUrl).toHaveBeenCalledWith(channelId, apiBaseUrl); + expect(console.info).toHaveBeenCalledWith( + `Successfully set api-base-url to ${apiBaseUrl}`, + ); + }); + + it("should set LIFF base URL for a specific channel", async () => { + const channelId = "456"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getCurrentChannelId).mockImplementationOnce(() => undefined); + vi.mocked(setLiffBaseUrl).mockImplementationOnce(() => {}); + + const command = makeSetCommand(); + await command.parseAsync([ + "_", + "set", + "liff-base-url", + liffBaseUrl, + "--channel-id", + channelId, + ]); + + expect(setLiffBaseUrl).toHaveBeenCalledWith(channelId, liffBaseUrl); + expect(console.info).toHaveBeenCalledWith( + `Successfully set liff-base-url to ${liffBaseUrl}`, + ); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeSetCommand(); + + await expect( + command.parseAsync(["_", "set", "api-base-url", "https://example.com"]), + ).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(setApiBaseUrl).not.toHaveBeenCalled(); + expect(setLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error with invalid config key", async () => { + const command = makeSetCommand(); + + await expect( + command.parseAsync(["_", "set", "invalid-key", "value"]), + ).rejects.toThrow("process.exit called with code 1"); + + expect(console.error).toHaveBeenCalledWith( + "Error: Unknown config key: invalid-key", + ); + expect(console.error).toHaveBeenCalledWith( + "Valid keys: api-base-url, liff-base-url", + ); + expect(setApiBaseUrl).not.toHaveBeenCalled(); + expect(setLiffBaseUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/config/commands/set.ts b/src/config/commands/set.ts index dab79c3..37ca852 100644 --- a/src/config/commands/set.ts +++ b/src/config/commands/set.ts @@ -1,10 +1,7 @@ import { createCommand } from "commander"; -import { - getCurrentChannelId, - setApiBaseUrl, - setLiffBaseUrl, -} from "../../channel/stores/channels.js"; +import { getCurrentChannelId } from "../../channel/stores/channels.js"; import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; +import { setApiBaseUrl, setLiffBaseUrl } from "../../channel/baseUrl.js"; type ConfigValue = { key: string; diff --git a/src/serve/serveAction.test.ts b/src/serve/serveAction.test.ts index fd3d9d5..997c6f0 100644 --- a/src/serve/serveAction.test.ts +++ b/src/serve/serveAction.test.ts @@ -49,6 +49,11 @@ describe("serveAction", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); @@ -79,7 +84,12 @@ describe("serveAction", () => { localProxyPort: "9000", }; - vi.mocked(resolveChannel).mockResolvedValueOnce({ + vi.mocked(resolveChannel).mockResolvedValue({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + }).mockResolvedValue({ accessToken: "token", expiresIn: 3600, secret: "secret", diff --git a/src/serve/serveAction.ts b/src/serve/serveAction.ts index 1fb8a73..cd0d1b0 100644 --- a/src/serve/serveAction.ts +++ b/src/serve/serveAction.ts @@ -1,13 +1,11 @@ import { spawn } from "node:child_process"; import { LiffApiClient } from "../api/liff.js"; import { resolveChannel } from "../channel/resolveChannel.js"; -import { - getCurrentChannelId, - getLiffBaseUrl, -} from "../channel/stores/channels.js"; +import { getCurrentChannelId } from "../channel/stores/channels.js"; import { ProxyInterface } from "./proxy/proxy-interface.js"; import resolveEndpointUrl from "./resolveEndpointUrl.js"; import pc from "picocolors"; +import { getLiffBaseUrl } from "../channel/baseUrl.js"; export const serveAction = async ( options: { @@ -57,7 +55,7 @@ export const serveAction = async ( } const httpsUrl = await proxy.connect(endpointUrl); - const liffBaseUrl = getLiffBaseUrl(currentChannelId); + const liffBaseUrl = await getLiffBaseUrl(currentChannelId); const liffUrl = new URL(liffBaseUrl); liffUrl.pathname = options.liffId;