From 7455b2291e24299e88f5ca2e2c8c5c8be8e4409f Mon Sep 17 00:00:00 2001 From: Junya Ishihara Date: Tue, 15 Sep 2026 17:12:52 +0900 Subject: [PATCH] =?UTF-8?q?=E8=B3=BC=E8=AA=ADURL=E3=81=8C=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=83=91=E3=82=B9=E9=85=8D=E4=B8=8B=E3=81=A7404?= =?UTF-8?q?=E3=81=AB=E3=81=AA=E3=82=8B=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=81=97=E3=80=81=E3=82=AB=E3=83=AC=E3=83=B3?= =?UTF-8?q?=E3=83=80=E3=83=BC=E5=90=8D=E3=82=92=E5=88=86=E3=81=8B=E3=82=8A?= =?UTF-8?q?=E3=82=84=E3=81=99=E3=81=8F=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 購読URLの404 本番は https://tukumana.si.aoyama.ac.jp/shift2/ のようにサブパス配下で 配信されているが、購読URLを window.location.origin を基準に組み立てていたため /shift2 が欠落し、Googleカレンダーに追加しても404で購読できなかった。 誤: https://tukumana.si.aoyama.ac.jp/api/ical/user/... → 404 正: https://tukumana.si.aoyama.ac.jp/shift2/api/ical/user/... → 200 config.API_BASE_URL(サブパスを含む)を基準に組み立てる toAbsoluteApiUrl() を 追加し、自分用・管理者用の両方の購読URLで使うようにした。 ## カレンダー名 購読したカレンダーが全員「シフト管理」という名前で追加され、 カレンダーアプリ上で誰のシフトか区別できなかった。 個人用: 「〇〇さんのシフト」(シフト0件時は「マイシフト」) 全体用: 「全員のシフト」 ## テスト js/modules/shifts.js にエクスポートガードを追加し、テストから実コードを 直接 import して検証している(docs/refactoring/phase-1-test-foundation.md の方針)。 本リポジトリで最初のガード追加。ブラウザでは module が未定義のため no-op。 旧挙動に一時的に戻して該当テストが落ちることも確認済み。 テスト: 422 passed (31 suites) Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + help.html | 2 +- js/modules/shifts.js | 20 +++++-- server/src/__tests__/ICalService.test.ts | 39 ++++++++++++++ server/src/services/ICalService.ts | 17 ++++-- test/ical-url-subpath.test.js | 68 ++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 test/ical-url-subpath.test.js diff --git a/README.md b/README.md index f70285d..d116b1e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ ICAL_TOKEN=生成したトークン文字列 - **更新頻度**: Pull 型のため、カレンダーアプリのポーリング間隔に依存します。Google Calendar・Outlook は約24時間、Apple カレンダーは約15分が目安です(リアルタイム更新は現行の Google Calendar 直接同期が適切)。 - **連続シフトの統合**: 同一ユーザーの連続する30分シフトは1つのイベントにまとめて表示されます(例: 13:00-13:30 + 13:30-14:00 → 13:00-14:00)。 - **特別シフトも含む**: 通常シフト(`shifts`)と特別シフト(`special_shifts`)の両方が含まれます。 +- **カレンダー名**: 購読するとカレンダーアプリ上に、個人用は「〇〇さんのシフト」(シフトが0件の場合は「マイシフト」)、全体用は「全員のシフト」という名前で追加されます。複数のカレンダーを購読しても区別できます。 - **セキュリティ**: URLに含まれるトークンが認証情報です。購読URLは他人に共有しないでください。漏洩した場合は `.env` の `ICAL_TOKEN` を再生成してサーバーを再起動すると、全ユーザーの購読URLが失効し、新しいURLが発行されます。 ## 使用技術 diff --git a/help.html b/help.html index a511d2f..3687ca2 100644 --- a/help.html +++ b/help.html @@ -541,7 +541,7 @@

設定の手順

6. 完了 -

左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。

+

左側のカレンダー一覧に「〇〇さんのシフト」(〇〇はあなたの名前)が追加され、自分のシフトが予定として表示されます。

diff --git a/js/modules/shifts.js b/js/modules/shifts.js index 976dd0a..138247a 100644 --- a/js/modules/shifts.js +++ b/js/modules/shifts.js @@ -112,6 +112,13 @@ function displayCapacityOnAdminCalendar(capacityData) { }); } +// サーバーが返す「/api/...」形式のパスを、貼り付け可能な絶対URLに変換する +// 本番は /shift2/ のようなサブパス配下で配信されるため、 +// window.location.origin ではなく config.API_BASE_URL を基準にする +function toAbsoluteApiUrl(relativeUrl) { + return config.API_BASE_URL.replace(/\/api$/, '') + relativeUrl; +} + // iCal購読URL(全シフト・管理者用)を入力欄にセットする async function updateIcalUrl() { const input = document.getElementById('icalAllUrl'); @@ -126,8 +133,7 @@ async function updateIcalUrl() { try { const result = await API.getAllIcalUrl(currentUser.email); if (result.success && result.data && result.data.url) { - // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする - input.value = new URL(result.data.url, window.location.origin).href; + input.value = toAbsoluteApiUrl(result.data.url); } else { input.value = '(購読URLを取得できませんでした)'; } @@ -164,8 +170,7 @@ async function updateMyIcalUrl() { try { const result = await API.getMyIcalUrl(currentUser.sub); if (result.success && result.data && result.data.url) { - // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする - input.value = new URL(result.data.url, window.location.origin).href; + input.value = toAbsoluteApiUrl(result.data.url); } else { input.value = '(購読URLを取得できませんでした)'; } @@ -854,3 +859,10 @@ async function deleteMyShift(buttonElement, uuids, isSpecial) { alert('シフト情報の取得に失敗しました。'); } } + +// テストから実コードを検証できるようにするエクスポートガード +// (ブラウザでは module が未定義のため no-op になる) +// 方針: docs/refactoring/phase-1-test-foundation.md +if (typeof module !== 'undefined' && module.exports) { + module.exports = { toAbsoluteApiUrl }; +} diff --git a/server/src/__tests__/ICalService.test.ts b/server/src/__tests__/ICalService.test.ts index ea78418..c47cc23 100644 --- a/server/src/__tests__/ICalService.test.ts +++ b/server/src/__tests__/ICalService.test.ts @@ -152,6 +152,45 @@ describe('ICalService', () => { }); }); + describe('カレンダー名', () => { + test('個人用カレンダーには本人の名前が入る(誰のシフトか分かるように)', () => { + const mockShifts = [ + { id: 1, uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也', + email: 'a@example.com', date: '2026-04-21', time_slot: '13:00-13:30', + calendar_event_id: null, created_at: '', updated_at: '' }, + ]; + (ShiftModel.getByUserId as jest.Mock).mockReturnValue(mockShifts); + + const result = ICalService.generateForUser('user-1'); + + expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト'); + }); + + test('名前が取得できない場合でも「マイシフト」として識別できる', () => { + const result = ICalService.generateForUser('user-1'); + + expect(result).toContain('X-WR-CALNAME:マイシフト'); + }); + + test('特別シフトしかない場合も本人の名前が入る', () => { + const mockApps = [ + { uuid: 'app-1', special_shift_uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也', + email: 'a@example.com', time_slot: '13:00-13:30', date: '2026-04-20', created_at: '', updated_at: '' }, + ]; + (SpecialShiftApplicationModel.getAllWithShiftInfo as jest.Mock).mockReturnValue(mockApps); + + const result = ICalService.generateForUser('user-1'); + + expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト'); + }); + + test('全体用カレンダーは全員分だと分かる名前にする', () => { + const result = ICalService.generateAll(); + + expect(result).toContain('X-WR-CALNAME:全員のシフト'); + }); + }); + describe('generateForUser(userId)', () => { test('特定ユーザーの特別シフト申請のみ含まれる', () => { const mockApps = [ diff --git a/server/src/services/ICalService.ts b/server/src/services/ICalService.ts index b89ae84..83165c4 100644 --- a/server/src/services/ICalService.ts +++ b/server/src/services/ICalService.ts @@ -71,7 +71,7 @@ export class ICalService { * 同一ユーザーの連続するシフトはひとつのイベントにまとめる */ static generateAll(): string { - const cal = ical({ name: 'シフト管理', timezone: TIMEZONE }); + const cal = ical({ name: '全員のシフト', timezone: TIMEZONE }); addMergedShiftEvents(cal, ShiftModel.getAll()); addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(undefined)); @@ -84,10 +84,19 @@ export class ICalService { * 同一ユーザーの連続するシフトはひとつのイベントにまとめる */ static generateForUser(userId: string): string { - const cal = ical({ name: 'シフト管理', timezone: TIMEZONE }); + const shifts = ShiftModel.getByUserId(userId); + const specialApps = SpecialShiftApplicationModel.getAllWithShiftInfo(userId); - addMergedShiftEvents(cal, ShiftModel.getByUserId(userId)); - addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(userId)); + // カレンダーアプリ上で「誰のシフトか」が分かるよう本人の名前を使う。 + // シフトが1件もない場合は名前を取得できないため既定名にする。 + const userName = shifts[0]?.user_name || specialApps[0]?.user_name; + const cal = ical({ + name: userName ? `${userName}さんのシフト` : 'マイシフト', + timezone: TIMEZONE + }); + + addMergedShiftEvents(cal, shifts); + addMergedSpecialShiftApplicationEvents(cal, specialApps); return cal.toString(); } diff --git a/test/ical-url-subpath.test.js b/test/ical-url-subpath.test.js new file mode 100644 index 0000000..5c4a6cd --- /dev/null +++ b/test/ical-url-subpath.test.js @@ -0,0 +1,68 @@ +/** + * iCal購読URLのサブパス対応テスト + * + * 回帰防止: 本番は https://tukumana.si.aoyama.ac.jp/shift2/ のように + * サブパス配下で配信されている。 + * window.location.origin を基準に絶対URLを組み立てると + * /shift2 が欠落し、購読URLが 404 になる。 + * + * このテストは実コード(js/modules/shifts.js)を直接 import して検証する。 + * 方針: docs/refactoring/phase-1-test-foundation.md + */ + +const { toAbsoluteApiUrl } = require('../js/modules/shifts.js'); + +describe('iCal購読URLのサブパス対応', () => { + afterEach(() => { + delete global.config; + }); + + describe('サブパス配下(本番相当: /shift2/)', () => { + beforeEach(() => { + global.config = { API_BASE_URL: 'https://tukumana.si.aoyama.ac.jp/shift2/api' }; + }); + + test('購読URLに /shift2 が含まれる', () => { + const url = toAbsoluteApiUrl('/api/ical/user/112204345149724665942?token=abc'); + + expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/user/112204345149724665942?token=abc'); + }); + + test('/shift2 が欠落しない(欠落すると本番で404になる)', () => { + const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc'); + + expect(url).toContain('/shift2/api/ical/'); + expect(url).not.toBe('https://tukumana.si.aoyama.ac.jp/api/ical/user/u1?token=abc'); + }); + + test('管理者用(全シフト)URLにも /shift2 が含まれる', () => { + const url = toAbsoluteApiUrl('/api/ical/all?token=admin'); + + expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/all?token=admin'); + }); + + test('トークンのクエリが壊れない', () => { + const url = toAbsoluteApiUrl('/api/ical/user/u1?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY'); + + expect(url).toContain('?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY'); + }); + }); + + describe('ルート直下(ローカル開発相当)', () => { + beforeEach(() => { + global.config = { API_BASE_URL: 'http://localhost:3000/api' }; + }); + + test('サブパスがない場合もそのまま組み立てられる', () => { + const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc'); + + expect(url).toBe('http://localhost:3000/api/ical/user/u1?token=abc'); + }); + + test('余分なパスが混入しない', () => { + const url = toAbsoluteApiUrl('/api/ical/all?token=abc'); + + expect(url).toBe('http://localhost:3000/api/ical/all?token=abc'); + }); + }); +});