feat(calendar): 실연동 일정·반복(recurrence)·일정 에디터·드래그/뷰 개선
- Google/Outlook 캘린더 실연동 라우터, 회의 머티리얼라이즈 갱신 - 반복 일정·RSVP·알림, EvEditor(생성/편집) 추가, 월/주/일 뷰·미니캘린더 개선 - 라우팅 [[...slug]], 관련 테스트(api_calendar·phase16·views) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
9db9ed7242
commit
ebba607f49
@ -1,45 +1,49 @@
|
||||
# backend/tests/test_api_calendar.py
|
||||
def test_week_bundle(client):
|
||||
# phase-16+: 일정 mock 시드 제거 → 기본은 빈 상태. 기능 테스트는 직접 생성.
|
||||
from tests._factories import make_cal_event, make_meeting, make_meeting_action
|
||||
|
||||
|
||||
def test_week_bundle_empty(client):
|
||||
r = client.get("/api/calendar/week")
|
||||
assert r.status_code == 200
|
||||
b = r.json()
|
||||
assert b["today"] == 8 and b["week"] == [7, 8, 9, 10, 11, 12, 13]
|
||||
assert b["weekdays"] == ["일", "월", "화", "수", "목", "금", "토"]
|
||||
assert len(b["calendars"]) == 5 and len(b["events"]) == 22 and len(b["focus_blocks"]) == 4
|
||||
team = next(c for c in b["calendars"] if c["id"] == "team")
|
||||
assert team["count"] == sum(1 for e in b["events"] if e["cal"] == "team")
|
||||
# 일정 연결 전: 캘린더/이벤트/포커스 모두 비어 있음
|
||||
assert b["calendars"] == [] and b["events"] == [] and b["focus_blocks"] == []
|
||||
|
||||
|
||||
def test_week_bundle_with_event(client, session):
|
||||
s, _ = session
|
||||
make_cal_event(s, id="e1", day=8, start="14:00", end="15:00", title="분기 전략 미팅",
|
||||
cal="work", soon=True)
|
||||
b = client.get("/api/calendar/week").json()
|
||||
assert any(e["id"] == "e1" for e in b["events"])
|
||||
|
||||
|
||||
def test_day_bundle_has_meeting_and_actions(client):
|
||||
def test_day_bundle_with_meeting_and_actions(client, session):
|
||||
s, _ = session
|
||||
make_cal_event(s, id="e3", day=8, start="10:00", end="10:30", title="팀 스탠드업", cal="team")
|
||||
make_meeting(s, event_id="e3", phase="done")
|
||||
make_meeting_action(s, id="ea1", event_id="e3", idx=0, text="피드백 정리",
|
||||
when_text="오늘", source="event")
|
||||
b = client.get("/api/calendar/day/8").json()
|
||||
e3 = next(e for e in b["events"] if e["id"] == "e3")
|
||||
assert e3["has_meeting"] is True
|
||||
assert e3["meet_label"] == "회의 노트 · 액션 보기"
|
||||
assert [a["text"] for a in e3["actions"]] == [
|
||||
"온보딩 와이어프레임 피드백 정리",
|
||||
"푸시 알림 QA 결과 공유",
|
||||
]
|
||||
e6 = next(e for e in b["events"] if e["id"] == "e6")
|
||||
assert e6["soon"] is True and e6["meet_label"] == "사전 브리핑 보기"
|
||||
assert e6["people"] == ["대표님", "재무팀장", "나 외 3명"]
|
||||
assert [a["text"] for a in e3["actions"]] == ["피드백 정리"]
|
||||
|
||||
|
||||
def test_meeting_phases(client):
|
||||
def test_meeting_phase(client, session):
|
||||
s, _ = session
|
||||
make_cal_event(s, id="e3", title="팀 스탠드업", cal="team")
|
||||
make_meeting(s, event_id="e3", phase="done")
|
||||
assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done"
|
||||
assert client.get("/api/calendar/meetings/e4").json()["phase"] == "live"
|
||||
up = client.get("/api/calendar/meetings/e6").json()
|
||||
assert up["phase"] == "upcoming" and len(up["agenda"]) == 3 and len(up["docs"]) == 2
|
||||
oo = client.get("/api/calendar/meetings/e7").json()
|
||||
assert oo["one_on_one"] is True and oo["person"]["name"] == "민서"
|
||||
assert len(oo["talking_points"]) == 3
|
||||
|
||||
|
||||
def test_meeting_404(client):
|
||||
assert client.get("/api/calendar/meetings/nope").status_code == 404
|
||||
|
||||
|
||||
def test_day_focus_blocks(client):
|
||||
def test_day_focus_blocks_empty(client):
|
||||
b = client.get("/api/calendar/day/8").json()
|
||||
assert len(b["focus_blocks"]) == 4
|
||||
deep = [f for f in b["focus_blocks"] if f["type"] == "deep"]
|
||||
assert len(deep) == 1 and deep[0]["title"] == "분기 리포트 초안 마무리"
|
||||
assert b["focus_blocks"] == [] # 포커스 시드 제거
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
# backend/tests/test_phase16_calendar.py — phase-16 Outlook/M365 캘린더 OAuth + Graph sync
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.connectors import oauth
|
||||
from app.connectors.calendar import real_outlook
|
||||
from app.connectors.calendar.real_outlook import OutlookCalendarConnector
|
||||
from app.connectors.registry import ConnectorRegistry, _impl
|
||||
from app.crypto import encrypt_token
|
||||
from app.models import CalEvent, ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ms_creds(monkeypatch):
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid")
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec")
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload, status=200):
|
||||
self._p = payload
|
||||
self.status_code = status
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"http {self.status_code}")
|
||||
|
||||
|
||||
class _Client:
|
||||
"""httpx.Client 컨텍스트매니저 스텁 — 페이지를 순서대로 반환."""
|
||||
|
||||
def __init__(self, pages):
|
||||
self._pages = list(pages)
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None, params=None):
|
||||
self.calls.append((url, params))
|
||||
return _Resp(self._pages.pop(0))
|
||||
|
||||
|
||||
# ── OAuth: Outlook 캘린더 authorize URL (microsoft 테넌트 + Calendars.Read) ──
|
||||
def test_outlook_calendar_authorize_url(session, ms_creds):
|
||||
s, _ = session
|
||||
url = oauth.start_oauth(s, "calendar", "outlook_calendar", redirect_after="/settings")
|
||||
assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
|
||||
assert "code_challenge=" in url and "state=" in url # PKCE
|
||||
assert "Calendars.Read" in url and "offline_access" in url
|
||||
assert "Mail.Read" not in url # 캘린더 스코프엔 메일 권한 없음
|
||||
|
||||
|
||||
# ── registry: provider 로 Google/Outlook 캘린더 분기 ──
|
||||
def test_calendar_dispatch_by_provider(session):
|
||||
s, _ = session
|
||||
assert _impl("calendar", "real", "outlook_calendar").__name__ == "OutlookCalendarConnector"
|
||||
assert _impl("calendar", "real", "google_calendar").__name__ == "GoogleCalendarConnector"
|
||||
out_acct = ConnectorAccount(
|
||||
id="ca-cal-o",
|
||||
domain=ConnectorDomain.calendar,
|
||||
provider="outlook_calendar",
|
||||
mode=ConnectorMode.real,
|
||||
)
|
||||
assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookCalendarConnector)
|
||||
|
||||
|
||||
# ── fetch: calendarView 페이지네이션(nextLink) + isCancelled 스킵 + CalEvent upsert ──
|
||||
def test_outlook_calendar_fetch_paginates_and_upserts(session, monkeypatch):
|
||||
s, _ = session
|
||||
acct = ConnectorAccount(
|
||||
id="ca-cal-outlook-1",
|
||||
domain=ConnectorDomain.calendar,
|
||||
provider="outlook_calendar",
|
||||
mode=ConnectorMode.real,
|
||||
state=ConnState.connected,
|
||||
external_account_id="me@corp.com",
|
||||
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
|
||||
)
|
||||
s.add(acct)
|
||||
s.commit()
|
||||
|
||||
ev = lambda i, **k: { # noqa: E731
|
||||
"id": f"ev-{i}",
|
||||
"subject": f"미팅 {i}",
|
||||
"start": {"dateTime": "2026-06-12T09:00:00.0000000"},
|
||||
"end": {"dateTime": "2026-06-12T09:30:00.0000000"},
|
||||
"location": {"displayName": "룸"},
|
||||
**k,
|
||||
}
|
||||
pages = [
|
||||
{"value": [ev(1), ev(2, isCancelled=True)], "@odata.nextLink": "https://graph/next?p=2"},
|
||||
{"value": [ev(3)]},
|
||||
]
|
||||
client = _Client(pages)
|
||||
monkeypatch.setattr(real_outlook.httpx, "Client", lambda *a, **k: client)
|
||||
|
||||
conn = OutlookCalendarConnector(acct)
|
||||
res = conn.sync(s)
|
||||
|
||||
# 취소 이벤트 제외하고 2건만 적재(ev-1, ev-3)
|
||||
assert res.upserted == 2
|
||||
titles = {e.title for e in s.exec(select(CalEvent)).all()}
|
||||
assert titles == {"미팅 1", "미팅 3"}
|
||||
# 2번째 호출은 nextLink URL + params=None (쿼리는 nextLink 에 포함)
|
||||
assert client.calls[0][0].endswith("/me/calendarView")
|
||||
assert client.calls[1] == ("https://graph/next?p=2", None)
|
||||
|
||||
|
||||
# ── API: /connectors/providers?domain=calendar → google + outlook 캘린더 ──
|
||||
def test_calendar_providers_endpoint(client, ms_creds, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid")
|
||||
get_settings.cache_clear()
|
||||
rows = client.get("/api/connectors/providers?domain=calendar").json()
|
||||
by = {r["provider"]: r for r in rows}
|
||||
assert set(by) == {"google_calendar", "outlook_calendar"}
|
||||
assert by["outlook_calendar"]["configured"] is True # ms_creds 설정됨
|
||||
assert by["outlook_calendar"]["label"] == "Outlook 캘린더"
|
||||
|
||||
|
||||
# ── 시작 검증: 미구성 outlook_calendar 는 400 ──
|
||||
def test_oauth_start_outlook_calendar_requires_microsoft_client_id(client):
|
||||
r = client.get("/api/connectors/oauth/start?domain=calendar&provider=outlook_calendar")
|
||||
assert r.status_code == 400
|
||||
@ -0,0 +1,8 @@
|
||||
// frontend/app/calendar/[[...slug]]/page.tsx — 일정 페이지 (옵셔널 캐치올: /calendar, /calendar/<id>)
|
||||
// <id> 세그먼트는 CalendarClient 가 useParams() 로 읽어 해당 일정으로 이동·오픈한다.
|
||||
import "@/styles/calendar.css";
|
||||
import { CalendarClient } from "@/components/calendar/CalendarClient";
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <CalendarClient />;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
// frontend/app/calendar/page.tsx — 일정 페이지 (server component; Topbar 는 layout)
|
||||
import "@/styles/calendar.css";
|
||||
import { CalendarClient } from "@/components/calendar/CalendarClient";
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <CalendarClient />;
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
// frontend/components/calendar/EvEditor.tsx — 새 일정 생성/편집 모달
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { calendarApi } from "@/lib/calendar/api";
|
||||
import { addHour } from "@/lib/calendar/time";
|
||||
import type { Calendar, CalEvent, EventWrite } from "@/lib/calendar/types";
|
||||
|
||||
// 쓰기 가능한 캘린더(Google = gcal-*, Outlook = ocal-*, 로컬 = local).
|
||||
function writableCalendars(cals: Calendar[]): Calendar[] {
|
||||
const ok = cals.filter(
|
||||
(c) => c.id.startsWith("gcal-") || c.id.startsWith("ocal-") || c.id === "local",
|
||||
);
|
||||
return ok.length ? ok : cals;
|
||||
}
|
||||
|
||||
export function EvEditor({
|
||||
calendars,
|
||||
initial,
|
||||
defaultDate,
|
||||
defaultTime,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
calendars: Calendar[];
|
||||
initial?: CalEvent | null; // 있으면 편집, 없으면 생성
|
||||
defaultDate: string; // 생성 시 기본 날짜(ISO)
|
||||
defaultTime?: string; // 생성 시 기본 시작시각("HH:MM", 슬롯 클릭)
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onDeleted?: () => void;
|
||||
}) {
|
||||
const editing = !!initial;
|
||||
const writable = writableCalendars(calendars);
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [date, setDate] = useState(initial?.date || defaultDate);
|
||||
const [allDay, setAllDay] = useState(editing ? !initial?.start : false);
|
||||
const [start, setStart] = useState(initial?.start || defaultTime || "09:00");
|
||||
const [end, setEnd] = useState(
|
||||
initial?.end || (defaultTime ? addHour(defaultTime, 1) : "10:00"),
|
||||
);
|
||||
const [loc, setLoc] = useState(initial?.loc ?? "");
|
||||
const [note, setNote] = useState(initial?.note ?? "");
|
||||
const [cal, setCal] = useState(initial?.cal || writable[0]?.id || "");
|
||||
const [people, setPeople] = useState((initial?.people ?? []).join(", "));
|
||||
const [rrule, setRrule] = useState(initial?.rrule ?? "");
|
||||
const [reminder, setReminder] = useState<number>(initial?.reminders?.[0] ?? -1); // -1 = 없음
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) {
|
||||
setErr("제목을 입력하세요");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
const body: EventWrite = {
|
||||
title: title.trim(),
|
||||
date,
|
||||
start: allDay ? "" : start,
|
||||
end: allDay ? "" : end,
|
||||
loc: loc.trim(),
|
||||
note: note.trim(),
|
||||
people: people
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
cal,
|
||||
rrule,
|
||||
reminders: reminder >= 0 ? [reminder] : [],
|
||||
};
|
||||
try {
|
||||
if (editing && initial) await calendarApi.updateEvent(initial.id, body);
|
||||
else await calendarApi.createEvent(body);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "저장하지 못했어요");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!initial) return;
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
await calendarApi.deleteEvent(initial.id);
|
||||
onDeleted?.();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "삭제하지 못했어요");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dp-backdrop" onClick={busy ? undefined : onClose} />
|
||||
<div className="eed" role="dialog" aria-modal="true" aria-label={editing ? "일정 편집" : "새 일정"}>
|
||||
<div className="eed-head">
|
||||
<h2>{editing ? "일정 편집" : "새 일정"}</h2>
|
||||
<button className="eed-x" aria-label="닫기" onClick={onClose} disabled={busy}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="eed-field">
|
||||
<span>제목</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="일정 제목"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="eed-field">
|
||||
<span>날짜</span>
|
||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="eed-check">
|
||||
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} />
|
||||
<span>종일</span>
|
||||
</label>
|
||||
|
||||
{!allDay && (
|
||||
<div className="eed-times">
|
||||
<label className="eed-field">
|
||||
<span>시작</span>
|
||||
<input type="time" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="eed-field">
|
||||
<span>종료</span>
|
||||
<input type="time" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="eed-field">
|
||||
<span>장소</span>
|
||||
<input value={loc} onChange={(e) => setLoc(e.target.value)} placeholder="(선택)" />
|
||||
</label>
|
||||
|
||||
<label className="eed-field">
|
||||
<span>캘린더</span>
|
||||
<select value={cal} onChange={(e) => setCal(e.target.value)}>
|
||||
{writable.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="eed-times">
|
||||
<label className="eed-field">
|
||||
<span>반복</span>
|
||||
<select value={rrule} onChange={(e) => setRrule(e.target.value)}>
|
||||
<option value="">반복 안 함</option>
|
||||
<option value="FREQ=DAILY">매일</option>
|
||||
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">주중 매일(평일)</option>
|
||||
<option value="FREQ=WEEKLY">매주</option>
|
||||
<option value="FREQ=MONTHLY">매월</option>
|
||||
<option value="FREQ=YEARLY">매년</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="eed-field">
|
||||
<span>알림</span>
|
||||
<select value={reminder} onChange={(e) => setReminder(Number(e.target.value))}>
|
||||
<option value={-1}>없음</option>
|
||||
<option value={0}>시작 시각</option>
|
||||
<option value={5}>5분 전</option>
|
||||
<option value={10}>10분 전</option>
|
||||
<option value={15}>15분 전</option>
|
||||
<option value={30}>30분 전</option>
|
||||
<option value={60}>1시간 전</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="eed-field">
|
||||
<span>참석자</span>
|
||||
<input
|
||||
value={people}
|
||||
onChange={(e) => setPeople(e.target.value)}
|
||||
placeholder="이메일 — 쉼표로 구분 (선택)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="eed-field">
|
||||
<span>메모</span>
|
||||
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="(선택)" />
|
||||
</label>
|
||||
|
||||
{err && (
|
||||
<div className="eed-err" role="alert">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="eed-acts">
|
||||
{editing && (
|
||||
<button className="eed-del" onClick={del} disabled={busy}>
|
||||
<Icon name="trash" /> 삭제
|
||||
</button>
|
||||
)}
|
||||
<span className="eed-spacer" />
|
||||
<button className="eed-cancel" onClick={onClose} disabled={busy}>
|
||||
취소
|
||||
</button>
|
||||
<button className="eed-save" onClick={save} disabled={busy}>
|
||||
{busy ? "저장 중…" : editing ? "저장" : "추가"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue