diff --git a/src/features/access/components/connectorAdapters.tsx b/src/features/access/components/connectorAdapters.tsx index 8bf023a1..dcd35027 100644 --- a/src/features/access/components/connectorAdapters.tsx +++ b/src/features/access/components/connectorAdapters.tsx @@ -1,8 +1,8 @@ import { useAuth } from "../../../context/useAuth"; import { TokenRow } from "../../settings/components/TokenRow"; -import { JiraCredentialAddForm } from "../../settings/components/jira/JiraCredentialAddForm"; -import { JiraCredentialRow } from "../../settings/components/jira/JiraCredentialRow"; -import type { JiraCredentialsDto } from "../../../services/sources/jiraService"; +import { AtlassianCredentialAddForm } from "../../settings/components/atlassian/AtlassianCredentialAddForm"; +import { AtlassianCredentialRow } from "../../settings/components/atlassian/AtlassianCredentialRow"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService"; import type { AccessAddFormProps, AccessRowProps } from "../types"; /** @@ -18,20 +18,20 @@ export function GithubTokenRow({ entry, onSaved }: AccessRowProps) { return ; } -export function JiraAccessRow({ entry, onSaved }: AccessRowProps) { - return ; +export function AtlassianAccessRow({ entry, onSaved }: AccessRowProps) { + return ; } /** - * The Jira form needs a default account email, which every other connector's - * form does not — so the profile is read here rather than threaded through the - * view and the registry. + * The Atlassian form needs a default account email, which every other + * connector's form does not — so the profile is read here rather than + * threaded through the view and the registry. */ -export function JiraAccessAddForm({ onClose, onSaved }: AccessAddFormProps) { +export function AtlassianAccessAddForm({ onClose, onSaved }: AccessAddFormProps) { const { profile } = useAuth(); return ( - ({ Row: GithubTokenRow, }); -export const jiraConnector = defineAccessConnector({ - id: "jira", - label: "Jira", +export const atlassianConnector = defineAccessConnector({ + id: "atlassian", + label: "Atlassian", icon: Ticket, noun: { one: "credential", many: "credentials" }, addLabel: "Add credential", emptyTitle: "No credentials yet", - emptyDescription: "Add a Jira API token to connect and ingest Jira instances.", + emptyDescription: "Add an Atlassian API token to connect Jira instances and Confluence spaces.", useEntries: () => { - const { credentials, loaded, error, isRefreshing, reload } = useJiraCredentials(); + const { credentials, loaded, error, isRefreshing, reload } = useAtlassianCredentials(); // Credentials are keyed by `(userEmail, tokenName)` server-side; the same - // name may exist for two Jira accounts, so the key needs both. + // name may exist for two Atlassian accounts, so the key needs both. const entries = useMemo( () => credentials.map((credential) => ({ @@ -72,8 +76,8 @@ export const jiraConnector = defineAccessConnector({ return { entries, loaded, error, isRefreshing, reload }; }, - AddForm: JiraAccessAddForm, - Row: JiraAccessRow, + AddForm: AtlassianAccessAddForm, + Row: AtlassianAccessRow, }); /** @@ -83,4 +87,4 @@ export const jiraConnector = defineAccessConnector({ * form — no new tab, no page change, no layout change. Keep the list short * enough that the source filter stays useful. */ -export const ACCESS_CONNECTORS: AccessConnector[] = [githubConnector, jiraConnector]; +export const ACCESS_CONNECTORS: AccessConnector[] = [githubConnector, atlassianConnector]; diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx index fc9e3367..2183bc80 100644 --- a/src/features/admin/components/CreateProjectWizard.tsx +++ b/src/features/admin/components/CreateProjectWizard.tsx @@ -13,10 +13,12 @@ import { import { addDraftSource, connectDraftSources, + createConfluenceDraft, createDraftSourceFromDiscovery, createJiraDraft, createUploadDraft, hasFailedSources, + isValidConfluenceSpaceId, removeDraftSource, setDraftSourceOwner, type DraftSource, @@ -24,8 +26,8 @@ import { import { sortOwnerOptions } from "../sourceOwners"; import type { DiscoverySelection } from "../../data-ingestion/components/GithubRepositoryDiscovery"; import type { SourceSystem } from "../../data-ingestion/types"; -import { useJiraCredentials } from "../../settings/hooks/useJiraCredentials"; -import type { JiraCredentialsDto } from "../../../services/sources/jiraService"; +import { useAtlassianCredentials } from "../../settings/hooks/useAtlassianCredentials"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService"; import { useGithubTokens } from "../../settings/hooks/useGithubTokens"; import { getDisplayName } from "../data"; import type { AdminUser } from "../types"; @@ -72,8 +74,8 @@ const STEP_INDEX: Record, number> = { review: 3, }; -// All three connectors can now be staged from the add-source sub-flow. -const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD"]; +// All four connectors can now be staged from the add-source sub-flow. +const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]; /** * Transactional create-project wizard: everything is drafted locally across the @@ -141,6 +143,10 @@ export function CreateProjectWizard({ const [jiraUrl, setJiraUrl] = useState(""); const [jiraCredentialName, setJiraCredentialName] = useState(""); + const [confluenceBaseUrl, setConfluenceBaseUrl] = useState(""); + const [confluenceSpaceId, setConfluenceSpaceId] = useState(""); + const [confluenceCredentialName, setConfluenceCredentialName] = useState(""); + // Upload files staged in memory; uploaded during provisioning once a project // id exists. const [uploadFiles, setUploadFiles] = useState([]); @@ -181,6 +187,9 @@ export function CreateProjectWizard({ }; const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA"; + const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE"; + // Jira and Confluence share the same Atlassian credential store, so one + // instance of the hook backs both detail screens' pickers. const { credentials: jiraCredentials, loaded: jiraCredentialsLoaded, @@ -188,7 +197,7 @@ export function CreateProjectWizard({ isRefreshing: jiraCredentialsLoading, reload: reloadJiraCredentials, addCredentialLocally, - } = useJiraCredentials(isOpen && isJiraDetail); + } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail)); // The token list arrives asynchronously; adopt the first token as soon as it // does (and heal a stale selection) so discovery is usable on the first open. @@ -202,8 +211,10 @@ export function CreateProjectWizard({ }); }, [effectiveTokenNames]); - // Same adoption pattern for the Jira credential picker: select the first - // stored credential once the list arrives, keeping a still-valid choice. + // Same adoption pattern for the Jira and Confluence credential pickers: + // select the first stored credential once the list arrives, keeping a + // still-valid choice. Both fields share the list, so a credential just added + // from either detail screen is adopted here too. useEffect(() => { if (!jiraCredentialsLoaded || jiraCredentialsLoading) return; @@ -214,6 +225,12 @@ export function CreateProjectWizard({ ? current : jiraCredentials[0].displayName; }); + setConfluenceCredentialName((current) => { + if (jiraCredentials.length === 0) return ""; + return current && jiraCredentials.some((credential) => credential.displayName === current) + ? current + : jiraCredentials[0].displayName; + }); }); }, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]); @@ -240,6 +257,12 @@ export function CreateProjectWizard({ void Promise.resolve().then(loadManagerCandidates); }, [isOpen, loadManagerCandidates]); + const resetConfluenceDraftFields = () => { + setConfluenceBaseUrl(""); + setConfluenceSpaceId(""); + setConfluenceCredentialName(""); + }; + const resetWizard = () => { setPhase("details"); setName(""); @@ -255,6 +278,7 @@ export function CreateProjectWizard({ setAddType("GITHUB"); setGithubSelection([]); resetJiraDraftFields(); + resetConfluenceDraftFields(); setUploadFiles([]); setCreatedProjectId(""); }; @@ -268,6 +292,7 @@ export function CreateProjectWizard({ const resetSourceDraftFields = () => { setGithubSelection([]); resetJiraDraftFields(); + resetConfluenceDraftFields(); setUploadFiles([]); }; @@ -397,16 +422,26 @@ export function CreateProjectWizard({ await loadTokenNames(); }; - const handleCredentialSaved = async (credential: JiraCredentialsDto) => { + const handleCredentialSaved = async (credential: AtlassianCredentialDto) => { addCredentialLocally(credential); setJiraCredentialName(credential.displayName); await reloadJiraCredentials(); }; + const handleConfluenceCredentialSaved = async (credential: AtlassianCredentialDto) => { + addCredentialLocally(credential); + setConfluenceCredentialName(credential.displayName); + await reloadJiraCredentials(); + }; + const selectedJiraCredential = jiraCredentials.find( (credential) => credential.displayName === jiraCredentialName, ); + const selectedConfluenceCredential = jiraCredentials.find( + (credential) => credential.displayName === confluenceCredentialName, + ); + const canAddSource = addType === "GITHUB" ? githubSelection.length > 0 @@ -414,7 +449,13 @@ export function CreateProjectWizard({ ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) : addType === "UPLOAD" ? uploadFiles.length > 0 - : false; + : addType === "CONFLUENCE" + ? Boolean( + confluenceBaseUrl.trim() && + isValidConfluenceSpaceId(confluenceSpaceId) && + selectedConfluenceCredential, + ) + : false; const commitAddSource = () => { if (!canAddSource) return; @@ -442,6 +483,17 @@ export function CreateProjectWizard({ } else if (addType === "UPLOAD") { const displayName = uploadFiles.length === 1 ? uploadFiles[0].name : "Uploaded documents"; setSources((current) => addDraftSource(current, createUploadDraft(displayName, uploadFiles))); + } else if (addType === "CONFLUENCE" && selectedConfluenceCredential) { + setSources((current) => + addDraftSource( + current, + createConfluenceDraft({ + baseUrl: confluenceBaseUrl.trim(), + spaceId: confluenceSpaceId.trim(), + credentialName: selectedConfluenceCredential.displayName, + }), + ), + ); } closeAddSource(); @@ -804,6 +856,21 @@ export function CreateProjectWizard({ current.filter((_, position) => position !== index), ), }} + confluence={{ + baseUrl: confluenceBaseUrl, + spaceId: confluenceSpaceId, + credentialName: confluenceCredentialName, + credentials: jiraCredentials, + credentialsLoaded: jiraCredentialsLoaded, + credentialsLoading: jiraCredentialsLoading, + credentialsError: jiraCredentialsError, + defaultUserEmail: null, + onBaseUrlChange: setConfluenceBaseUrl, + onSpaceIdChange: setConfluenceSpaceId, + onCredentialNameChange: setConfluenceCredentialName, + onSubmit: commitAddSource, + onCredentialSaved: handleConfluenceCredentialSaved, + }} /> ) : ( ; } + if (source.type === "CONFLUENCE") { + return ; + } + return ; } @@ -95,6 +100,10 @@ function sourceDetail(source: DraftSource): string { return source.url; } + if (source.type === "CONFLUENCE") { + return `${source.baseUrl} (${source.spaceId})`; + } + return source.tokenName; } diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx index cd18ac03..5532a1c5 100644 --- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx +++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx @@ -19,12 +19,13 @@ import { type DiscoverySelection, } from "../../../../data-ingestion/components/GithubRepositoryDiscovery"; import { JiraConnectStep } from "../../../../data-ingestion/components/JiraConnectStep"; +import { ConfluenceConnectStep } from "../../../../data-ingestion/components/ConfluenceConnectStep"; import { SourceTypeStep } from "../../../../data-ingestion/components/SourceTypeStep"; import { FileUploadZone } from "../../../../knowledge-base/components/FileUploadZone"; import { TokenAddForm } from "../../../../settings/components/TokenAddForm"; -import { JiraCredentialAddForm } from "../../../../settings/components/jira/JiraCredentialAddForm"; +import { AtlassianCredentialAddForm } from "../../../../settings/components/atlassian/AtlassianCredentialAddForm"; import type { SourceSystem } from "../../../../data-ingestion/types"; -import type { JiraCredentialsDto } from "../../../../../services/sources/jiraService"; +import type { AtlassianCredentialDto } from "../../../../../services/sources/atlassianService"; /** * Below this width the credential form stays inline (phone/tablet); at or above @@ -59,7 +60,7 @@ type JiraDetailProps = { displayName: string; url: string; credentialName: string; - credentials: JiraCredentialsDto[]; + credentials: AtlassianCredentialDto[]; credentialsLoaded: boolean; credentialsLoading: boolean; credentialsError: string | null; @@ -71,7 +72,7 @@ type JiraDetailProps = { /** Enter in a field stages the source (guarded), matching "Add to list". */ onSubmit: () => void; /** Adds the new credential to the list, selects it, and reconciles with the server. */ - onCredentialSaved: (credential: JiraCredentialsDto) => Promise; + onCredentialSaved: (credential: AtlassianCredentialDto) => Promise; }; /** Upload detail — files are staged in memory and uploaded during provisioning. */ @@ -82,6 +83,26 @@ type UploadDetailProps = { onRemoveFile: (index: number) => void; }; +/** Confluence detail — a staged form; nothing connects until provisioning. */ +type ConfluenceDetailProps = { + baseUrl: string; + spaceId: string; + credentialName: string; + credentials: AtlassianCredentialDto[]; + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; + /** Prefill for the inline "add credential" form's account-email field. */ + defaultUserEmail: string | null; + onBaseUrlChange: (value: string) => void; + onSpaceIdChange: (value: string) => void; + onCredentialNameChange: (value: string) => void; + /** Enter in a field stages the source (guarded), matching "Add to list". */ + onSubmit: () => void; + /** Adds the new credential to the list, selects it, and reconciles with the server. */ + onCredentialSaved: (credential: AtlassianCredentialDto) => Promise; +}; + type AddSourceFlowProps = { step: AddSourceStep; selectedType: SourceSystem; @@ -98,6 +119,7 @@ type AddSourceFlowProps = { github: GithubDetailProps; jira: JiraDetailProps; upload: UploadDetailProps; + confluence: ConfluenceDetailProps; /** * Told when the desktop credential companion opens/closes, so the wizard can * slide its modal left to make room for it. @@ -387,12 +409,12 @@ function JiraDetail({ return (
( - void; +}) { + // Only hint "nothing stored" once the list has loaded, so the chip does not + // flash while credentials are still being fetched. + const missingCredential = confluence.credentialsLoaded && confluence.credentials.length === 0; + + return ( +
+ ( + + )} + /> + + +
+ ); +} + /** One-line brief shown under the detail header, per source type. */ const DETAIL_SUBTITLE: Record = { GITHUB: "Pick the repositories to index, then add them to your source list.", JIRA: "Point to your Jira instance and pick a credential, then add it to the list.", UPLOAD: "Files are staged now and uploaded right after the project is created.", + CONFLUENCE: "Point to your Confluence space and pick a credential, then add it to the list.", }; /** @@ -516,6 +589,7 @@ export function AddSourceFlow({ github, jira, upload, + confluence, onCompanionOpenChange, }: AddSourceFlowProps) { const prefersReducedMotion = useReducedMotion(); @@ -529,6 +603,12 @@ export function AddSourceFlow({ onAddFiles={upload.onAddFiles} onRemoveFile={upload.onRemoveFile} /> + ) : selectedType === "CONFLUENCE" ? ( + ) : ( ); diff --git a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx index f8953dd8..7c8b6d22 100644 --- a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx +++ b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx @@ -1,4 +1,4 @@ -import { GitBranch, Ticket, FileText } from "lucide-react"; +import { GitBranch, Ticket, FileText, BookOpen } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; import { UserAvatar } from "../../../../../components/common/UserAvatar"; import { SourceTypeBadge } from "../../../../data-ingestion/components/SourceTypeBadge"; @@ -28,12 +28,14 @@ const typeIcons: Record> GITHUB: GitBranch, JIRA: Ticket, UPLOAD: FileText, + CONFLUENCE: BookOpen, }; const typeLabels: Record = { GITHUB: "GitHub", JIRA: "Jira", UPLOAD: "Upload", + CONFLUENCE: "Confluence", }; function sourceTitle(source: DraftSource): string { diff --git a/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx b/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx index 6bd43267..fb4591c6 100644 --- a/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx +++ b/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx @@ -43,7 +43,7 @@ export function WizardSourcesStep({ onRemove={onRemove} ownerOptions={ownerOptions} onOwnerChange={onOwnerChange} - emptyMessage="No sources yet. Add a GitHub repo, Jira project, or files to start." + emptyMessage="No sources yet. Add a GitHub repo, Jira project, Confluence space, or files to start." />
) : ( diff --git a/src/features/data-ingestion/components/ConfluenceConnectStep.tsx b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx new file mode 100644 index 00000000..2ac10f3b --- /dev/null +++ b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx @@ -0,0 +1,146 @@ +import { AlertTriangle } from "lucide-react"; +import { DropdownSelect } from "../../../components/ui/DropdownSelect.tsx"; +import { Field } from "../../../components/ui/Field.tsx"; +import { Input } from "../../../components/ui/Input.tsx"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts"; + +/** + * Controlled Confluence connect form for a base URL, space ID, and a stored + * Atlassian credential. + * + * Shared between the Data Ingestion "Add source" wizard and the project-creation + * wizard so the Confluence connect experience is identical in both places. + */ +export function ConfluenceConnectStep({ + baseUrl, + spaceId, + credentialName, + credentials, + credentialsLoaded, + credentialsLoading, + credentialsError, + isBusy = false, + canIngest = true, + ingestBlockedReason, + errorMessage, + onBaseUrlChange, + onSpaceIdChange, + onCredentialNameChange, + onSubmit, + suppressMissingCredentialNotice = false, +}: { + baseUrl: string; + spaceId: string; + credentialName: string; + credentials: AtlassianCredentialDto[]; + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; + isBusy?: boolean; + canIngest?: boolean; + ingestBlockedReason?: string; + errorMessage?: string | null; + onBaseUrlChange: (value: string) => void; + onSpaceIdChange: (value: string) => void; + onCredentialNameChange: (value: string) => void; + onSubmit?: () => void; + /** + * Hides the built-in "no stored credential" banner. Set when the parent shows + * its own missing-credential hint (e.g. the wizard's compact notice next to + * its inline "Add credential" button) so the message is not duplicated. + */ + suppressMissingCredentialNotice?: boolean; +}) { + const hasCredentials = credentials.length > 0; + const showNoCredentials = + credentialsLoaded && !credentialsLoading && !hasCredentials && !suppressMissingCredentialNotice; + + return ( +
{ + event.preventDefault(); + onSubmit?.(); + }} + > + {!canIngest && ( +
+ {ingestBlockedReason ?? "You can only connect sources to projects you manage."} +
+ )} + + {showNoCredentials && ( +
+ No Atlassian credentials are stored for your account. Add one under Settings, Access + Tokens, Atlassian first, then come back to connect. +
+ )} + + + onBaseUrlChange(event.target.value)} + placeholder="https://your-domain.atlassian.net" + required + /> + + + + onSpaceIdChange(event.target.value)} + placeholder="e.g. 123456" + required + /> + + +
+ Credential + ({ + value: credential.displayName, + label: `${credential.displayName} - ${credential.userEmail}`, + })) + : [ + { + value: "", + label: credentialsLoading ? "Loading credentials..." : "No credentials", + }, + ] + } + onChange={onCredentialNameChange} + disabled={isBusy || !hasCredentials} + /> +
+ + {credentialsError && ( +
+ + {credentialsError} +
+ )} + + {errorMessage && ( +
+ + {errorMessage} +
+ )} +
+ ); +} diff --git a/src/features/data-ingestion/components/JiraConnectStep.tsx b/src/features/data-ingestion/components/JiraConnectStep.tsx index 35c0424c..eacb7e76 100644 --- a/src/features/data-ingestion/components/JiraConnectStep.tsx +++ b/src/features/data-ingestion/components/JiraConnectStep.tsx @@ -3,7 +3,7 @@ import { AlertTriangle } from "lucide-react"; import { DropdownSelect } from "../../../components/ui/DropdownSelect.tsx"; import { Field } from "../../../components/ui/Field.tsx"; import { Input } from "../../../components/ui/Input.tsx"; -import type { JiraCredentialsDto } from "../../../services/sources/jiraService.ts"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts"; /** * Jira connect form for an instance URL and a credential owned by the @@ -33,7 +33,7 @@ export function JiraConnectStep({ displayName: string; url: string; credentialName: string; - credentials: JiraCredentialsDto[]; + credentials: AtlassianCredentialDto[]; credentialsLoaded: boolean; credentialsLoading: boolean; credentialsError: string | null; @@ -73,7 +73,7 @@ export function JiraConnectStep({ {showNoCredentials && (
No Jira credentials are stored for your account. Add one under Settings, Access Tokens, - Jira first, then come back to connect. + Atlassian first, then come back to connect.
)} @@ -119,7 +119,7 @@ export function JiraConnectStep({

Jira account emails are stored with each credential. Manage them under Settings, Access - Tokens, Jira. + Tokens, Atlassian.

{credentialsError && (
diff --git a/src/features/data-ingestion/components/RunDetailsPanel.tsx b/src/features/data-ingestion/components/RunDetailsPanel.tsx index f030817a..ed693cc7 100644 --- a/src/features/data-ingestion/components/RunDetailsPanel.tsx +++ b/src/features/data-ingestion/components/RunDetailsPanel.tsx @@ -14,7 +14,7 @@ import { DetailsSideDrawer } from "../../../components/layout/DetailsSideDrawer" import { DrawerCard } from "../../admin/components/DrawerCard"; import { formatDateTime, - formatJiraInstanceDomain, + formatInstanceDomain, formatNumber, getAiSyncStatusLabel, getRunStatusLabel, @@ -181,7 +181,7 @@ export function RunDetailsPanel({ run, sourceLabel, onClose }: RunDetailsPanelPr */ function buildOriginRow(run: IngestionRun): { label: string; value: string } | null { if (run.sourceSystem === "JIRA") { - return run.sourceId ? { label: "Domain", value: formatJiraInstanceDomain(run.sourceId) } : null; + return run.sourceId ? { label: "Domain", value: formatInstanceDomain(run.sourceId) } : null; } if (run.sourceSystem === "GITHUB") { @@ -189,6 +189,10 @@ function buildOriginRow(run: IngestionRun): { label: string; value: string } | n return owner ? { label: "Owner", value: owner } : null; } + if (run.sourceSystem === "CONFLUENCE") { + return run.sourceId ? { label: "Space", value: run.sourceId } : null; + } + return null; } diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx index 45330555..cdef3a5a 100644 --- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx +++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx @@ -1,5 +1,6 @@ import { ArrowUp, + BookOpen, CalendarClock, Clock3, Database, @@ -34,7 +35,10 @@ import { SOURCE_META, } from "../data.ts"; import type { DataSource, LoadingState } from "../types.ts"; -import { GithubRepositorySyncSettings } from "./GithubRepositorySyncSettings.tsx"; +import { + GithubRepositorySyncSettings, + type SyncScheduleConfig, +} from "./GithubRepositorySyncSettings.tsx"; import { SourceStatusChip } from "./SourceStatusChip.tsx"; import { SourceTypeBadge } from "./SourceTypeBadge.tsx"; @@ -57,6 +61,13 @@ type SourceDetailsPanelProps = { instanceUrl: string, request: Omit, ) => Promise; + /** Loads the sync schedule of a Confluence space (by connection id). */ + onLoadConfluenceConfig?: (connectionId: string) => Promise; + /** Saves the sync schedule of a Confluence space (by connection id). */ + onSaveConfluenceConfig?: ( + connectionId: string, + request: ConfigureGithubRepositoryRequest, + ) => Promise; /** Enables/disables the source in the connector (allow/deny for ingestion). */ onSetSourceEnabled?: ( repository: NonNullable, @@ -85,6 +96,8 @@ export function SourceDetailsPanel({ onSaveRepositoryConfig, onLoadJiraConfig, onSaveJiraConfig, + onLoadConfluenceConfig, + onSaveConfluenceConfig, onSetSourceEnabled, onSetJiraSourceEnabled, onUnlinkSource, @@ -99,14 +112,23 @@ export function SourceDetailsPanel({ const Icon = SOURCE_META[source.sourceSystem].icon; const repository = source.githubRepository; const jira = source.jiraInstance ?? null; + const confluence = source.confluenceSpace ?? null; const isJira = source.sourceSystem === "JIRA"; + const isConfluence = source.sourceSystem === "CONFLUENCE"; const isUpdating = updateState === "loading"; const isRefreshing = refreshState === "loading"; - // Update is available for a GitHub repo (needs owner/name) or a Jira instance - // (needs its URL); enable/disable and unlink stay GitHub-only for now. + // Update is available for a GitHub repo (needs owner/name), a Jira instance + // (needs its URL), or a Confluence space (needs its ID). const canUpdate = onUpdateSource !== undefined && - ((source.sourceSystem === "GITHUB" && repository !== null) || (isJira && jira !== null)); + ((source.sourceSystem === "GITHUB" && repository !== null) || + (isJira && jira !== null) || + (isConfluence && Boolean(confluence?.connectionId))); + // GitHub and Jira start an asynchronous run, so "Update started" is the whole + // story here. Confluence ingests synchronously and its caller already reports + // the outcome (completed, partial or failed) — a second toast from here would + // duplicate it and, on a failed run, contradict it. + const reportsUpdateItself = isConfluence; const canManageRepositoryConfig = canManageSyncSettings && source.sourceSystem === "GITHUB" && @@ -119,6 +141,12 @@ export function SourceDetailsPanel({ jira !== null && onLoadJiraConfig !== undefined && onSaveJiraConfig !== undefined; + const canManageConfluenceConfig = + canManageSyncSettings && + isConfluence && + Boolean(confluence?.connectionId) && + onLoadConfluenceConfig !== undefined && + onSaveConfluenceConfig !== undefined; const canToggleEnabled = canManageSyncSettings && source.sourceSystem === "GITHUB" && @@ -132,17 +160,28 @@ export function SourceDetailsPanel({ const isTogglingEnabled = enabledState === "loading"; // Authorization is presence-based — the parent only passes onUnlinkSource when // the caller may manage the project's sources. GitHub needs the connection's - // repositoryId; Jira is identified by its instance URL. + // repositoryId; Jira is identified by its instance URL, Confluence by its + // connection id. const canUnlinkSource = onUnlinkSource !== undefined && ((source.sourceSystem === "GITHUB" && repository !== null && repository.repositoryId !== null) || - (isJira && jira !== null)); + (isJira && jira !== null) || + (isConfluence && Boolean(confluence?.connectionId))); const isUnlinking = unlinkState === "loading"; // Noun for the unlink copy: GitHub sources are repositories, Jira sources are - // instances. Keeps each connector's wording accurate. - const removableNoun = isJira ? "instance" : "repository"; + // instances, Confluence sources are spaces. Keeps each connector's wording + // accurate. + const removableNoun = isJira ? "instance" : isConfluence ? "space" : "repository"; + // What removal actually costs, per connector. A GitHub repository and a Jira + // instance are shared between projects and only lose the project association, + // so re-linking restores the source as it was. A Confluence connection belongs + // to a single project, so removing it deletes the connection itself — the + // pages already ingested stay, but the space has to be set up again. + const removalHint = isConfluence + ? "The pages it already ingested are kept. Connecting the space again sets it up from scratch." + : `The ${removableNoun} and its artifacts are kept. You can re-link it later.`; // GitHub exposes one timestamp per resource type; Jira refreshes issue data // (including comments and change history) as one combined resource. const hasResourceSyncTimes = @@ -217,14 +256,18 @@ export function SourceDetailsPanel({ try { await onUpdateSource(source); setUpdateState("success"); - toast.success("Update started", { - description: "Details refresh while ingestion runs.", - }); + if (!reportsUpdateItself) { + toast.success("Update started", { + description: "Details refresh while ingestion runs.", + }); + } } catch (error) { setUpdateState("error"); - toast.error(error instanceof Error ? error.message : "Couldn't start the update."); + if (!reportsUpdateItself) { + toast.error(error instanceof Error ? error.message : "Couldn't start the update."); + } } - }, [canUpdate, onUpdateSource, source, toast]); + }, [canUpdate, onUpdateSource, reportsUpdateItself, source, toast]); const handleConfirmUnlink = useCallback(async () => { if (!onUnlinkSource) return; @@ -308,16 +351,26 @@ export function SourceDetailsPanel({ }} disabled={!canUpdate || isRefreshing} loading={isUpdating} - icon={isJira ? : } + icon={ + isJira ? ( + + ) : isConfluence ? ( + + ) : ( + + ) + } title={ canUpdate ? undefined : isJira ? "Instance updates need the Jira instance URL." - : "Repository updates need GitHub owner and repository name." + : isConfluence + ? "Space updates need the Confluence space ID." + : "Repository updates need GitHub owner and repository name." } > - {isJira ? "Update instance" : "Update repo"} + {isJira ? "Update instance" : isConfluence ? "Update space" : "Update repo"}
diff --git a/src/features/data-ingestion/components/SourceTypeStep.tsx b/src/features/data-ingestion/components/SourceTypeStep.tsx index f7ba2dfc..a8d29822 100644 --- a/src/features/data-ingestion/components/SourceTypeStep.tsx +++ b/src/features/data-ingestion/components/SourceTypeStep.tsx @@ -7,8 +7,9 @@ import type { SourceSystem } from "../types.ts"; * wizard. Each card carries its own description so the differences between the * options -- the actual decision being made here -- are visible. Which * connectors count as available depends on the context (`availableTypes`): both - * wizards now wire GitHub, Jira and Upload, but any connector left out of - * `availableTypes` still renders with a "Soon" badge instead of being hidden. + * wizards now wire GitHub, Jira, Upload and Confluence, but any connector left + * out of `availableTypes` still renders with a "Soon" badge instead of being + * hidden. */ export function SourceTypeStep({ selectedType, @@ -38,7 +39,10 @@ export function SourceTypeStep({

{description}

)} -
+ {/* Two columns from `sm` up rather than one row per connector: with four + source types a three-column grid leaves a single card stranded on the + second row, and the cards carry a description each. */} +
{SOURCE_SYSTEMS.map((sourceSystem) => { const meta = SOURCE_META[sourceSystem]; const Icon = meta.icon; diff --git a/src/features/data-ingestion/data.ts b/src/features/data-ingestion/data.ts index 3c136126..d0d0b957 100644 --- a/src/features/data-ingestion/data.ts +++ b/src/features/data-ingestion/data.ts @@ -1,5 +1,6 @@ import { AlertTriangle, + BookOpen, CheckCircle2, CircleSlash, FileText, @@ -21,8 +22,9 @@ import type { SourceSystem, } from "./types.ts"; import type { JiraInstanceDto } from "../../services/sources/jiraService.ts"; +import type { ConfluenceConnectionDto } from "../../services/sources/confluenceService.ts"; -export const SOURCE_SYSTEMS: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD"]; +export const SOURCE_SYSTEMS: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]; export const SOURCE_META: Record = { GITHUB: { @@ -43,6 +45,12 @@ export const SOURCE_META: Record = { icon: FileText, description: "Indexes manually uploaded documentation, markdown files and project knowledge.", }, + CONFLUENCE: { + name: "Confluence Space", + type: "Confluence", + icon: BookOpen, + description: "Indexes pages, hierarchical documents and spaces from Confluence Cloud.", + }, }; export const INGESTION_RUN_LIMIT = 50; @@ -224,6 +232,151 @@ export function createUploadSourceFromInstance(status: SourceInstanceIngestionSt failedItems: status.failedItems, githubRepository: null, jiraInstance: null, + confluenceSpace: null, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: null, + }; +} + +/** + * Maps a CONFLUENCE status row from `/api/v1/ingestion-sources/status` into the + * full {@link DataSource} model rendered on the ingestion page. + */ +export function createConfluenceSourceFromInstance( + status: SourceInstanceIngestionStatus, + connection?: ConfluenceConnectionDto | null, + connectorEnabled?: boolean, +): DataSource { + const meta = SOURCE_META.CONFLUENCE; + const backendStatus: BackendProjectSourceStatus = + status.enabled === false ? "DISABLED" : status.connectionStatus; + const hasErrors = status.failedCount > 0; + const hasNeverSynced = status.lastRunTime === null; + + return { + sourceId: connection?.id ?? status.sourceId, + sourceSystem: "CONFLUENCE", + name: status.displayName, + type: meta.type, + icon: meta.icon, + status: getSourceStatusFromBackend(backendStatus), + backendStatus, + statusLabel: getBackendSourceStatusLabel(backendStatus), + ingestionStatus: getSourceStatus(hasNeverSynced, hasErrors, null), + ingestionStatusLabel: + !hasNeverSynced && !hasErrors + ? "Synced" + : getSourceStatusLabel(hasNeverSynced, hasErrors, null), + statusView: deriveSourceStatus({ + backendStatus, + hasErrors, + hasNeverSynced, + connectorEnabled, + }), + artifacts: status.artifactCount, + lastSync: formatDateTime(status.lastRunTime), + nextSync: connection?.nextSyncAt ? formatDateTime(connection.nextSyncAt) : "Not scheduled", + errors: status.failedCount, + description: meta.description, + lastRunAt: status.lastRunTime, + latestIngestedCount: status.ingestedCount, + latestUpdatedCount: status.updatedCount, + deletedCount: status.deletedCount, + totalArtifactCount: status.artifactCount, + runIds: [], + sharesSourceSystem: false, + failedItems: status.failedItems, + githubRepository: null, + jiraInstance: null, + // Same shape as the connection-built card below: the details drawer only + // renders the space name and credential rows when they are present, and a + // card must not lose them just because a status row exists for it. + confluenceSpace: connection + ? { + connectionId: connection.id, + baseUrl: connection.baseUrl, + spaceId: connection.spaceId, + spaceKey: connection.spaceKey, + spaceName: connection.spaceName, + credentialName: connection.credentialName, + } + : null, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: null, + }; +} + +/** + * Creates a DataSource directly from a {@link ConfluenceConnectionDto}, + * matching against recent ingestion runs for counters and sync status. + */ +export function createConfluenceSourceFromConnection( + connection: ConfluenceConnectionDto, + runs: IngestionRun[] = [], + connectorEnabled?: boolean, +): DataSource { + const meta = SOURCE_META.CONFLUENCE; + const compositeRef = `${connection.baseUrl}|${connection.spaceId}`.toLowerCase(); + const latestRun = runs.find( + (r) => + r.sourceSystem === "CONFLUENCE" && + (r.sourceId?.toLowerCase() === compositeRef || + r.sourceId?.toLowerCase() === connection.spaceId.toLowerCase() || + r.sourceId?.toLowerCase() === connection.id.toLowerCase() || + r.sourceId?.toLowerCase() === connection.spaceKey.toLowerCase() || + r.repositoryId === connection.id), + ); + + const hasNeverSynced = !latestRun; + const hasErrors = (latestRun?.failedCount ?? 0) > 0; + const backendStatus: BackendProjectSourceStatus = + connection.sourceEnabled === false ? "DISABLED" : "CONNECTED"; + + return { + sourceId: connection.id, + sourceSystem: "CONFLUENCE", + name: connection.spaceName ?? connection.spaceKey ?? connection.spaceId, + type: meta.type, + icon: meta.icon, + status: getSourceStatusFromBackend(backendStatus), + backendStatus, + statusLabel: getBackendSourceStatusLabel(backendStatus), + ingestionStatus: getSourceStatus(hasNeverSynced, hasErrors, latestRun?.status ?? null), + ingestionStatusLabel: + !hasNeverSynced && !hasErrors + ? "Synced" + : getSourceStatusLabel(hasNeverSynced, hasErrors, latestRun?.status ?? null), + statusView: deriveSourceStatus({ + backendStatus, + hasErrors, + hasNeverSynced, + connectorEnabled, + }), + artifacts: (latestRun?.ingestedCount ?? 0) + (latestRun?.updatedCount ?? 0), + lastSync: formatDateTime(latestRun?.finishedAt ?? latestRun?.startedAt), + nextSync: connection.nextSyncAt ? formatDateTime(connection.nextSyncAt) : "Not scheduled", + errors: latestRun?.failedCount ?? 0, + description: meta.description, + lastRunAt: latestRun?.startedAt ?? null, + latestIngestedCount: latestRun?.ingestedCount ?? 0, + latestUpdatedCount: latestRun?.updatedCount ?? 0, + deletedCount: latestRun?.deletedCount ?? 0, + totalArtifactCount: (latestRun?.ingestedCount ?? 0) + (latestRun?.updatedCount ?? 0), + runIds: latestRun ? [latestRun.runId] : [], + sharesSourceSystem: false, + failedItems: latestRun?.failedItems ?? [], + githubRepository: null, + jiraInstance: null, + confluenceSpace: { + connectionId: connection.id, + baseUrl: connection.baseUrl, + spaceId: connection.spaceId, + spaceKey: connection.spaceKey, + spaceName: connection.spaceName, + credentialName: connection.credentialName, + }, lastCommitsSyncAt: null, lastIssuesSyncAt: null, lastPullRequestsSyncAt: null, @@ -525,11 +678,13 @@ export function getSourceLabel(sourceSystem: SourceSystem) { } /** - * The host of a Jira instance URL without the scheme, e.g. + * The host of an instance URL without the scheme, e.g. * `"acme.atlassian.net"` for `"https://acme.atlassian.net"`. Falls back to * stripping the scheme/trailing slash by hand if the value is not a valid URL. + * + * Used for the Jira instance and the Confluence base URL alike. */ -export function formatJiraInstanceDomain(instanceUrl: string): string { +export function formatInstanceDomain(instanceUrl: string): string { try { return new URL(instanceUrl).host; } catch { @@ -550,9 +705,23 @@ export function buildRunSourceLabels(sources: DataSource[]): Map const labels = new Map(); sources.forEach((source) => { - const ref = source.jiraInstance?.instanceUrl ?? source.githubRepository?.fullName; - if (ref && !labels.has(ref)) { - labels.set(ref, source.name); + if (source.jiraInstance?.instanceUrl) { + labels.set(source.jiraInstance.instanceUrl, source.name); + } + + if (source.githubRepository?.fullName) { + labels.set(source.githubRepository.fullName, source.name); + } + + if (source.confluenceSpace) { + const { connectionId, baseUrl, spaceId, spaceKey } = source.confluenceSpace; + if (connectionId) labels.set(connectionId, source.name); + if (baseUrl && spaceId) { + labels.set(`${baseUrl}|${spaceId}`, source.name); + labels.set(`${baseUrl}|${spaceId}`.toLowerCase(), source.name); + } + if (spaceKey) labels.set(spaceKey, source.name); + if (spaceId) labels.set(spaceId, source.name); } }); @@ -573,7 +742,7 @@ export function getRunSourceLabel(run: IngestionRun, labelBySourceRef?: Map { return ; case "PULL_REQUEST": return ; + case "PAGE": + return ; case "ORG_METADATA": // Neutral like COMMIT so it never reads as a status; organizations are // a distinct shape, not a success/warning condition. diff --git a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx index ded9be7f..a5aa3e6b 100644 --- a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx +++ b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx @@ -169,7 +169,8 @@ function drawerReducer(state: DrawerState, action: DrawerAction): DrawerState { /** * Determines whether an artifact should be rendered as Markdown. - * Issues, Pull Requests, Jira items, and Markdown files (.md/.markdown) are always rendered as Markdown. + * Issues, Pull Requests, Jira items, Confluence artifacts, and Markdown files (.md/.markdown) are always rendered as Markdown. + * Confluence is detected via sourceSystem or a /wiki/spaces/ URL pattern. */ const shouldRenderAsMarkdown = ( content: ArtifactContent | null | undefined, @@ -196,7 +197,10 @@ const shouldRenderAsMarkdown = ( sourceUrl.includes("/issues/") || sourceUrl.includes("/browse/"); - return content.mimeType.startsWith("text/markdown") || isPrOrIssue || isMd; + const isConfluence = + artifact?.sourceSystem === "CONFLUENCE" || sourceUrl.includes("/wiki/spaces/"); + + return content.mimeType.startsWith("text/markdown") || isPrOrIssue || isMd || isConfluence; }; // Hoisted to module scope so ReactMarkdown doesn't see a new array on every render diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts index 4deffaae..0e179f11 100644 --- a/src/features/knowledge-base/types.ts +++ b/src/features/knowledge-base/types.ts @@ -2,13 +2,13 @@ * Defines the specific entity type of an artifact. * Used by the UI to determine icon representations and filtering logic. */ -export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST" | "ORG_METADATA"; +export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST" | "PAGE" | "ORG_METADATA"; /** * Origin source of the artifact data. * Used to route API calls (e.g., Github vs internal Uploads). */ -export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD"; +export type SourceSystem = "GITHUB" | "JIRA" | "UPLOAD" | "CONFLUENCE"; /** * Core business entity representing any indexed piece of knowledge. diff --git a/src/features/settings/components/jira/JiraCredentialAddForm.tsx b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx similarity index 74% rename from src/features/settings/components/jira/JiraCredentialAddForm.tsx rename to src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx index ebfd7df2..1052a488 100644 --- a/src/features/settings/components/jira/JiraCredentialAddForm.tsx +++ b/src/features/settings/components/atlassian/AtlassianCredentialAddForm.tsx @@ -5,14 +5,17 @@ import { Field } from "../../../../components/ui/Field"; import { Input } from "../../../../components/ui/Input"; import { useToast } from "../../../../context/useToast"; import { parseApiError, describeRefreshFailure } from "../../../../services/apiError"; -import { addJiraCredential, type JiraCredentialsDto } from "../../../../services/sources/jiraService"; +import { + addAtlassianCredential, + type AtlassianCredentialDto, +} from "../../../../services/sources/atlassianService"; -type JiraCredentialAddFormProps = { - /** Login email used only as the initial Jira account email. */ +type AtlassianCredentialAddFormProps = { + /** Login email used only as the initial Atlassian account email. */ defaultUserEmail: string | null; onClose: () => void; /** Receives the credential just added, for an optimistic list update. */ - onSaved: (credential: JiraCredentialsDto) => Promise; + onSaved: (credential: AtlassianCredentialDto) => Promise; /** * When the form is already inside a titled container (the wizard's desktop * companion), drop its own card chrome and header so the inputs sit directly @@ -21,19 +24,20 @@ type JiraCredentialAddFormProps = { embedded?: boolean; }; -const ADD_FALLBACK = "Failed to add Jira credential."; +const ADD_FALLBACK = "Failed to add Atlassian credential."; /** - * Inline form for storing a Jira account email and API token for the - * authenticated user. The login email is only a convenience default because - * the Jira account may use a different address. + * Inline form for storing an Atlassian account email and API token for the + * authenticated user, shared by the Jira and Confluence connectors. The login + * email is only a convenience default because the Atlassian account may use a + * different address. */ -export function JiraCredentialAddForm({ +export function AtlassianCredentialAddForm({ defaultUserEmail, onClose, onSaved, embedded = false, -}: JiraCredentialAddFormProps) { +}: AtlassianCredentialAddFormProps) { const [userEmail, setUserEmail] = useState(defaultUserEmail ?? ""); const [name, setName] = useState(""); const [token, setToken] = useState(""); @@ -67,7 +71,7 @@ export function JiraCredentialAddForm({ setIsSaving(true); try { try { - await addJiraCredential({ + await addAtlassianCredential({ userEmail: trimmedEmail, tokenName: trimmedName, authToken: token.trim(), @@ -84,7 +88,7 @@ export function JiraCredentialAddForm({ onClose(); return; } - toast.success("Jira credential added"); + toast.success("Atlassian credential added"); onClose(); } finally { savingRef.current = false; @@ -95,7 +99,7 @@ export function JiraCredentialAddForm({ return (
void handleSubmit(e)} - aria-label="Add Jira credential" + aria-label="Add Atlassian credential" className={ embedded ? "" @@ -104,7 +108,7 @@ export function JiraCredentialAddForm({ > {!embedded && (
- New Jira credential + New Atlassian credential - {hasGithubSources || hasJiraSources ? ( + {syncSettingsProviders.length > 0 ? ( setIsAddOpen(false)} @@ -99,19 +99,19 @@ function renderGroup() { ); } -describe("AccessConnectorGroup — Jira", () => { +describe("AccessConnectorGroup — Atlassian", () => { beforeEach(() => { vi.clearAllMocks(); mockAuthEmail(EMAIL); - vi.mocked(getMyJiraCredentials).mockResolvedValue([cred("default")]); - vi.mocked(addJiraCredential).mockResolvedValue(undefined); - vi.mocked(changeJiraCredentialName).mockResolvedValue(cred("renamed")); - vi.mocked(changeJiraCredentialToken).mockResolvedValue(cred("default")); - vi.mocked(deleteJiraCredential).mockResolvedValue(undefined); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([cred("default")]); + vi.mocked(addAtlassianCredential).mockResolvedValue(undefined); + vi.mocked(changeAtlassianCredentialName).mockResolvedValue(cred("renamed")); + vi.mocked(changeAtlassianCredentialToken).mockResolvedValue(cred("default")); + vi.mocked(deleteAtlassianCredential).mockResolvedValue(undefined); }); - it("renders all credentials with their Jira account emails", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + it("renders all credentials with their Atlassian account emails", async () => { + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ cred("default"), cred("support", "support@corp.com"), ]); @@ -123,11 +123,11 @@ describe("AccessConnectorGroup — Jira", () => { expect(screen.getByText(EMAIL)).toBeInTheDocument(); expect(screen.getByText("support@corp.com")).toBeInTheDocument(); expect(screen.getByText("2 credentials")).toBeInTheDocument(); - expect(getMyJiraCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); + expect(getMyAtlassianCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); }); - it("keeps two credentials of the same name apart by Jira account", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + it("keeps two credentials of the same name apart by Atlassian account", async () => { + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ cred("default"), cred("default", "other@corp.com"), ]); @@ -143,23 +143,23 @@ describe("AccessConnectorGroup — Jira", () => { renderGroup(); expect(await screen.findByText("default")).toBeInTheDocument(); - expect(getMyJiraCredentials).toHaveBeenCalled(); + expect(getMyAtlassianCredentials).toHaveBeenCalled(); await userEvent.setup().click(screen.getByRole("button", { name: "open add form" })); - expect(screen.getByTestId("settings-jira-add-email")).toHaveValue(""); + expect(screen.getByTestId("settings-atlassian-add-email")).toHaveValue(""); }); it("shows the empty state when there are no credentials", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderGroup(); expect(await screen.findByText("No credentials yet")).toBeInTheDocument(); }); - it("prefills the Jira email and adds a credential", async () => { + it("prefills the Atlassian email and adds a credential", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("default")]) .mockResolvedValueOnce([cred("default"), cred("ci")]); @@ -167,13 +167,13 @@ describe("AccessConnectorGroup — Jira", () => { await screen.findByText("default"); await user.click(screen.getByRole("button", { name: "open add form" })); - expect(screen.getByTestId("settings-jira-add-email")).toHaveValue(EMAIL); - await user.type(screen.getByTestId("settings-jira-add-name"), "ci"); - await user.type(screen.getByTestId("settings-jira-add-token"), "secret-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + expect(screen.getByTestId("settings-atlassian-add-email")).toHaveValue(EMAIL); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "ci"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "secret-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(addJiraCredential).toHaveBeenCalledWith({ + expect(addAtlassianCredential).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "ci", authToken: "secret-token", @@ -182,21 +182,21 @@ describe("AccessConnectorGroup — Jira", () => { expect(await screen.findByText("2 credentials")).toBeInTheDocument(); }); - it("allows a Jira email different from the login email", async () => { + it("allows an Atlassian email different from the login email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); await user.click(screen.getByRole("button", { name: "open add form" })); - const emailInput = screen.getByTestId("settings-jira-add-email"); + const emailInput = screen.getByTestId("settings-atlassian-add-email"); await user.clear(emailInput); await user.type(emailInput, "jira-account@atlassian.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "work"); - await user.type(screen.getByTestId("settings-jira-add-token"), "tok"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "work"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "tok"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(addJiraCredential).toHaveBeenCalledWith({ + expect(addAtlassianCredential).toHaveBeenCalledWith({ userEmail: "jira-account@atlassian.com", tokenName: "work", authToken: "tok", @@ -204,19 +204,19 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("renames a credential using its stored Jira email", async () => { + it("renames a credential using its stored Atlassian email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-rename-open-default")); - const input = screen.getByTestId("settings-jira-rename-input-default"); + await user.click(screen.getByTestId("settings-atlassian-rename-open-default")); + const input = screen.getByTestId("settings-atlassian-rename-input-default"); await user.clear(input); await user.type(input, "renamed"); - await user.click(screen.getByTestId("settings-jira-rename-submit-default")); + await user.click(screen.getByTestId("settings-atlassian-rename-submit-default")); await waitFor(() => - expect(changeJiraCredentialName).toHaveBeenCalledWith({ + expect(changeAtlassianCredentialName).toHaveBeenCalledWith({ userEmail: EMAIL, oldName: "default", newName: "renamed", @@ -224,17 +224,17 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("rotates a credential token using its stored Jira email", async () => { + it("rotates a credential token using its stored Atlassian email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-rotate-open-default")); - await user.type(screen.getByTestId("settings-jira-rotate-input-default"), "new-token"); - await user.click(screen.getByTestId("settings-jira-rotate-submit-default")); + await user.click(screen.getByTestId("settings-atlassian-rotate-open-default")); + await user.type(screen.getByTestId("settings-atlassian-rotate-input-default"), "new-token"); + await user.click(screen.getByTestId("settings-atlassian-rotate-submit-default")); await waitFor(() => - expect(changeJiraCredentialToken).toHaveBeenCalledWith({ + expect(changeAtlassianCredentialToken).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "default", newToken: "new-token", @@ -242,20 +242,20 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("deletes a credential using its stored Jira email and refreshes", async () => { + it("deletes a credential using its stored Atlassian email and refreshes", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("default")]) .mockResolvedValueOnce([]); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-delete-open-default")); - await user.click(screen.getByTestId("settings-jira-delete-confirm-default")); + await user.click(screen.getByTestId("settings-atlassian-delete-open-default")); + await user.click(screen.getByTestId("settings-atlassian-delete-confirm-default")); await waitFor(() => - expect(deleteJiraCredential).toHaveBeenCalledWith({ + expect(deleteAtlassianCredential).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "default", }), diff --git a/tests/unit/features/access/AccessManagementView.test.tsx b/tests/unit/features/access/AccessManagementView.test.tsx index ad447dad..55d3e60d 100644 --- a/tests/unit/features/access/AccessManagementView.test.tsx +++ b/tests/unit/features/access/AccessManagementView.test.tsx @@ -21,17 +21,17 @@ vi.mock("../../../../src/services/sources/githubService", () => ({ deleteGithubPat: vi.fn(), })); -vi.mock("../../../../src/services/sources/jiraService", () => ({ - getMyJiraCredentials: vi.fn(), - addJiraCredential: vi.fn(), - changeJiraCredentialName: vi.fn(), - changeJiraCredentialToken: vi.fn(), - deleteJiraCredential: vi.fn(), +vi.mock("../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), + addAtlassianCredential: vi.fn(), + changeAtlassianCredentialName: vi.fn(), + changeAtlassianCredentialToken: vi.fn(), + deleteAtlassianCredential: vi.fn(), })); import { useAuth } from "../../../../src/context/useAuth"; import { getGithubPatNames } from "../../../../src/services/sources/githubService"; -import { getMyJiraCredentials } from "../../../../src/services/sources/jiraService"; +import { getMyAtlassianCredentials } from "../../../../src/services/sources/atlassianService"; /** * The filter is owned by whoever hosts the view, so that it survives the admin @@ -68,8 +68,8 @@ describe("AccessManagementView", () => { status: "authenticated", } as unknown as ReturnType); vi.mocked(getGithubPatNames).mockResolvedValue(["gh-default"]); - vi.mocked(getMyJiraCredentials).mockResolvedValue([ - { userEmail: "user@corp.com", displayName: "jira-default" }, + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ + { userEmail: "user@corp.com", displayName: "atlassian-default" }, ]); }); @@ -79,13 +79,13 @@ describe("AccessManagementView", () => { // Visibility rather than presence: a filtered-out source stays mounted, so // its rows are in the DOM either way. await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); expect(screen.getByText("gh-default")).toBeInTheDocument(); - expect(screen.getByText("jira-default")).toBeInTheDocument(); + expect(screen.getByText("atlassian-default")).toBeInTheDocument(); // Both sources fetched without anyone switching to them. expect(getGithubPatNames).toHaveBeenCalled(); - expect(getMyJiraCredentials).toHaveBeenCalled(); + expect(getMyAtlassianCredentials).toHaveBeenCalled(); }); it("renders one group per registered connector, in registry order", async () => { @@ -115,27 +115,27 @@ describe("AccessManagementView", () => { it("hides a source with no credentials by default and reveals it under All sources", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); await selectSource(user, "All sources"); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); expect(screen.getByText("No credentials yet")).toBeVisible(); }); it("keeps loading every source while unused ones are hidden", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); // The filter can only know which sources are in use because the hidden // ones are still mounted and still fetch. - await waitFor(() => expect(getMyJiraCredentials).toHaveBeenCalled()); + await waitFor(() => expect(getMyAtlassianCredentials).toHaveBeenCalled()); }); it("narrows the list to a single source and back", async () => { @@ -144,30 +144,30 @@ describe("AccessManagementView", () => { await waitFor(() => expect(screen.getByText("gh-default")).toBeInTheDocument()); - await selectSource(user, "Jira"); + await selectSource(user, "Atlassian"); await waitFor(() => expect(screen.getByTestId("access-group-github")).not.toBeVisible()); - expect(screen.getByTestId("access-group-jira")).toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).toBeVisible(); await selectSource(user, "All sources"); await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - expect(screen.getByTestId("access-group-jira")).toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).toBeVisible(); }); it("adds to a hidden source through the global add button", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); await user.click(screen.getByTestId("access-add-open")); - await user.click(await screen.findByTestId("access-add-source-jira")); + await user.click(await screen.findByTestId("access-add-source-atlassian")); // Choosing a source reveals it and opens its form, even though it holds // nothing and the default filter had hidden it. - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); - expect(screen.getByTestId("settings-jira-add-email")).toBeVisible(); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); + expect(screen.getByTestId("settings-atlassian-add-email")).toBeVisible(); }); it("takes the filter from its host instead of owning it", async () => { @@ -186,27 +186,27 @@ describe("AccessManagementView", () => { , ); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); expect(screen.getByTestId("access-group-github")).toBeVisible(); await user.click(screen.getByRole("combobox", { name: "Filter access by source" })); - await user.click(await screen.findByRole("option", { name: "Jira" })); + await user.click(await screen.findByRole("option", { name: "Atlassian" })); - expect(onSourceFilterChange).toHaveBeenCalledWith("jira"); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(onSourceFilterChange).toHaveBeenCalledWith("atlassian"); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); }); it("leaves the filter where the user put it and hides the source again on cancel", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); await user.click(screen.getByTestId("access-add-open")); - await user.click(await screen.findByTestId("access-add-source-jira")); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await user.click(await screen.findByTestId("access-add-source-atlassian")); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); // Revealing a source for the add form must not rewrite the filter. expect(screen.getByRole("combobox", { name: "Filter access by source" })).toHaveTextContent( @@ -215,7 +215,7 @@ describe("AccessManagementView", () => { await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); expect(screen.getByRole("combobox", { name: "Filter access by source" })).toHaveTextContent( "In use", ); @@ -226,23 +226,23 @@ describe("AccessManagementView", () => { renderView(); await user.click(screen.getByTestId("access-add-open")); - expect(await screen.findByTestId("access-add-source-jira")).toBeInTheDocument(); + expect(await screen.findByTestId("access-add-source-atlassian")).toBeInTheDocument(); await user.keyboard("{Escape}"); await waitFor(() => - expect(screen.queryByTestId("access-add-source-jira")).not.toBeInTheDocument(), + expect(screen.queryByTestId("access-add-source-atlassian")).not.toBeInTheDocument(), ); }); it("explains the empty view when no source is set up at all", async () => { vi.mocked(getGithubPatNames).mockResolvedValue([]); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); expect(await screen.findByText("No source is set up yet")).toBeVisible(); expect(screen.getByTestId("access-group-github")).not.toBeVisible(); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); }); }); diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx index c310ce07..42a8a598 100644 --- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx +++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx @@ -30,14 +30,23 @@ vi.mock("../../../../../src/services/ingestionService", () => ({ vi.mock("../../../../../src/services/sources/jiraService", () => ({ connectJiraInstance: vi.fn(), - getMyJiraCredentials: vi.fn(), - addJiraCredential: vi.fn(), +})); + +vi.mock("../../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), + addAtlassianCredential: vi.fn(), })); vi.mock("../../../../../src/services/knowledgeService", () => ({ knowledgeService: { uploadDocuments: vi.fn() }, })); +vi.mock("../../../../../src/services/sources/confluenceService", () => ({ + confluenceService: { + createConnection: vi.fn(), + }, +})); + import { projectService } from "../../../../../src/services/projectService"; import { addGithubPat, @@ -46,11 +55,12 @@ import { discoverRepositories, getGithubPatNames, } from "../../../../../src/services/sources/githubService"; +import { connectJiraInstance } from "../../../../../src/services/sources/jiraService"; import { - addJiraCredential, - connectJiraInstance, - getMyJiraCredentials, -} from "../../../../../src/services/sources/jiraService"; + addAtlassianCredential, + getMyAtlassianCredentials, +} from "../../../../../src/services/sources/atlassianService"; +import { confluenceService } from "../../../../../src/services/sources/confluenceService"; import { knowledgeService } from "../../../../../src/services/knowledgeService"; import { getIngestionSourceStatuses } from "../../../../../src/services/ingestionService"; @@ -189,15 +199,31 @@ describe("CreateProjectWizard", () => { vi.mocked(getIngestionSourceStatuses).mockResolvedValue([]); vi.mocked(getGithubPatNames).mockResolvedValue(["team-pat"]); vi.mocked(addGithubPat).mockResolvedValue(undefined); - vi.mocked(addJiraCredential).mockResolvedValue(undefined); + vi.mocked(addAtlassianCredential).mockResolvedValue(undefined); vi.mocked(projectService.assignUsersToProject).mockResolvedValue([]); vi.mocked(connectJiraInstance).mockResolvedValue(undefined); - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ { userEmail: "me@example.com", displayName: "Team token" }, ]); vi.mocked(knowledgeService.uploadDocuments).mockResolvedValue([ { filename: "spec.md", status: "success" }, ]); + vi.mocked(confluenceService.createConnection).mockResolvedValue({ + id: "conn-1", + projectId: "proj-new", + baseUrl: "https://acme.atlassian.net/wiki", + spaceId: "123456", + spaceKey: "DOCS", + spaceName: "Docs", + credentialName: "Team token", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + version: 1, + sourceEnabled: true, + }); }); /** From the sources step, stage a Jira board through the add-source sub-flow. */ @@ -214,6 +240,24 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add to list/i })); } + /** From the sources step, stage a Confluence space through the add-source sub-flow. */ + async function stageConfluenceSpace(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click( + screen.getByRole("button", { name: /indexes pages, hierarchical documents/i }), + ); + + // The stored credential is shared with Jira and adopted automatically. + await screen.findByText(/Team token - me@example.com/i); + + await user.type( + screen.getByLabelText("Confluence base URL"), + "https://acme.atlassian.net/wiki", + ); + await user.type(screen.getByLabelText("Space ID"), "123456"); + await user.click(screen.getByRole("button", { name: /add to list/i })); + } + it("keeps details on a blank name and explains why instead of a dead button", async () => { const user = userEvent.setup(); renderWizard(); @@ -535,6 +579,85 @@ describe("CreateProjectWizard", () => { ); }); + it("stages a Confluence space and connects it against the new project on Create", async () => { + const user = userEvent.setup(); + renderWizard(); + + await goToSources(user); + await stageConfluenceSpace(user); + + // Back on the sources list, the space is staged under its space ID (the + // draft has no readable name yet — that only exists after the backend + // resolves the connection). + expect(screen.getByText("Confluence Space 123456")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + await waitFor(() => + expect(vi.mocked(confluenceService.createConnection)).toHaveBeenCalledWith("proj-new", { + baseUrl: "https://acme.atlassian.net/wiki", + spaceId: "123456", + credentialName: "Team token", + pageAllowlist: [], + pageDenylist: [], + }), + ); + }); + + it("refuses to stage a Confluence space key, which is not the numeric space ID", async () => { + const user = userEvent.setup(); + renderWizard(); + + await goToSources(user); + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click( + screen.getByRole("button", { name: /indexes pages, hierarchical documents/i }), + ); + await screen.findByText(/Team token - me@example.com/i); + + await user.type( + screen.getByLabelText("Confluence base URL"), + "https://acme.atlassian.net/wiki", + ); + // "DOCS" is what Confluence's own UI shows, so it is the obvious thing to + // paste — and the backend would only reject it at provisioning time. + await user.type(screen.getByLabelText("Space ID"), "DOCS"); + + expect(screen.getByRole("button", { name: /add to list/i })).toBeDisabled(); + }); + + it("adds an Atlassian credential inline while staging a Confluence space and selects the new one", async () => { + vi.mocked(getMyAtlassianCredentials) + .mockResolvedValueOnce([]) // initial load: none stored + .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]); + const user = userEvent.setup(); + renderWizard(); + + await goToSources(user); + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click( + screen.getByRole("button", { name: /indexes pages, hierarchical documents/i }), + ); + + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "confluence-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); + + await waitFor(() => + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({ + userEmail: "new@example.com", + tokenName: "Fresh cred", + authToken: "confluence-token", + }), + ); + // The refreshed credential is adopted and shown as the picker's label. + await screen.findByText(/Fresh cred - new@example.com/i); + expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred"); + }); + it("stages uploaded files and uploads them against the new project on Create", async () => { const user = userEvent.setup(); renderWizard(); @@ -587,7 +710,7 @@ describe("CreateProjectWizard", () => { }); it("adds a Jira credential inline and selects the new one", async () => { - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([]) // initial load: none stored .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]); const user = userEvent.setup(); @@ -597,14 +720,14 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add source/i })); await user.click(screen.getByRole("button", { name: /indexes jira issues/i })); - await user.click(screen.getByRole("button", { name: /add jira credential/i })); - await user.type(screen.getByTestId("settings-jira-add-email"), "new@example.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "Fresh cred"); - await user.type(screen.getByTestId("settings-jira-add-token"), "jira-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "jira-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(vi.mocked(addJiraCredential)).toHaveBeenCalledWith({ + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({ userEmail: "new@example.com", tokenName: "Fresh cred", authToken: "jira-token", @@ -651,11 +774,11 @@ describe("CreateProjectWizard", () => { it("keeps the new Jira credential visible and selected when the post-add refetch fails", async () => { let added = false; - vi.mocked(addJiraCredential).mockImplementation(() => { + vi.mocked(addAtlassianCredential).mockImplementation(() => { added = true; return Promise.resolve(); }); - vi.mocked(getMyJiraCredentials).mockImplementation(() => + vi.mocked(getMyAtlassianCredentials).mockImplementation(() => added ? Promise.reject(new Error("Network error")) : Promise.resolve([]), ); const user = userEvent.setup(); @@ -665,16 +788,16 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add source/i })); await user.click(screen.getByRole("button", { name: /indexes jira issues/i })); - await user.click(screen.getByRole("button", { name: /add jira credential/i })); - await user.type(screen.getByTestId("settings-jira-add-email"), "new@example.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "Fresh cred"); - await user.type(screen.getByTestId("settings-jira-add-token"), "jira-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "jira-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); - await waitFor(() => expect(vi.mocked(addJiraCredential)).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledTimes(1)); await screen.findByText(/Fresh cred - new@example.com/i); expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred"); - expect(vi.mocked(addJiraCredential)).toHaveBeenCalledTimes(1); + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledTimes(1); }); it("does not create the project when cancelled on the first step", async () => { diff --git a/tests/unit/features/admin/projectSourcesDraft.test.ts b/tests/unit/features/admin/projectSourcesDraft.test.ts index 9ad391d7..6b712dbc 100644 --- a/tests/unit/features/admin/projectSourcesDraft.test.ts +++ b/tests/unit/features/admin/projectSourcesDraft.test.ts @@ -9,6 +9,7 @@ import { createUploadDraft, hasFailedSources, isSameSource, + isValidConfluenceSpaceId, removeDraftSource, setDraftSourceOwner, type DraftSource, @@ -130,6 +131,20 @@ describe("createJiraDraft / createUploadDraft", () => { }); }); +describe("isValidConfluenceSpaceId", () => { + it("accepts a numeric space id, ignoring surrounding whitespace", () => { + expect(isValidConfluenceSpaceId("123456")).toBe(true); + expect(isValidConfluenceSpaceId(" 123456 ")).toBe(true); + }); + + it("rejects a space key and anything else non-numeric", () => { + expect(isValidConfluenceSpaceId("ENG")).toBe(false); + expect(isValidConfluenceSpaceId("")).toBe(false); + expect(isValidConfluenceSpaceId("12a")).toBe(false); + expect(isValidConfluenceSpaceId("~123456")).toBe(false); + }); +}); + describe("isSameSource", () => { it("matches GitHub repositories by owner/name regardless of casing", () => { expect( diff --git a/tests/unit/features/chatbot/useAvailableSources.test.tsx b/tests/unit/features/chatbot/useAvailableSources.test.tsx new file mode 100644 index 00000000..77e55a63 --- /dev/null +++ b/tests/unit/features/chatbot/useAvailableSources.test.tsx @@ -0,0 +1,51 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useAvailableSources } from "../../../../src/features/chatbot/hooks/useAvailableSources"; + +const { mockListConnectors } = vi.hoisted(() => ({ mockListConnectors: vi.fn() })); + +vi.mock("../../../../src/services/connectorService", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + connectorService: { ...actual.connectorService, listConnectors: mockListConnectors }, + }; +}); + +function connector(id: string, enabled = true) { + return { + id, + name: `${id} connector`, + enabled, + firstConfiguredAt: null, + lastConfiguredAt: null, + }; +} + +describe("useAvailableSources", () => { + it("offers every enabled connector the chat can filter by, uploads included", async () => { + mockListConnectors.mockResolvedValue([ + connector("github"), + connector("jira"), + connector("confluence"), + ]); + + const { result } = renderHook(() => useAvailableSources()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect([...result.current.sources].sort()).toEqual(["CONFLUENCE", "GITHUB", "JIRA", "UPLOAD"]); + }); + + it("leaves out a disabled connector and any id the chat has no filter for", async () => { + mockListConnectors.mockResolvedValue([ + connector("github"), + connector("confluence", false), + connector("sonarqube"), + ]); + + const { result } = renderHook(() => useAvailableSources()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect([...result.current.sources].sort()).toEqual(["GITHUB", "UPLOAD"]); + }); +}); diff --git a/tests/unit/features/connectors/ConnectorSourcesSection.test.tsx b/tests/unit/features/connectors/ConnectorSourcesSection.test.tsx index d138ae13..955742d8 100644 --- a/tests/unit/features/connectors/ConnectorSourcesSection.test.tsx +++ b/tests/unit/features/connectors/ConnectorSourcesSection.test.tsx @@ -160,4 +160,61 @@ describe("ConnectorSourcesSection", () => { expect(screen.getByRole("button", { name: /save 1 change/i })).toBeInTheDocument(); }); + + it("offers a Confluence space the same include/exclude toggle as any other source", async () => { + const user = userEvent.setup(); + const confluenceConnector: ConnectorListItem = { + ...connector, + id: "confluence", + name: "Confluence Cloud Connector", + meta: { + label: "Confluence Cloud Connector", + description: "Pages and spaces from connected Confluence Cloud tenants.", + icon: GitBranch, + }, + }; + + const space = { + id: "11111111-1111-1111-1111-111111111111", + name: "Engineering", + url: "https://acme.atlassian.net/wiki/spaces/ENG", + enabled: true, + }; + let patchUrl: string | null = null; + + server.use( + http.get("/api/v1/connectors/confluence/sources", () => + HttpResponse.json({ connectorId: "confluence", sources: [space] }), + ), + http.patch("/api/v1/connectors/confluence/sources/status", ({ request }) => { + patchUrl = request.url; + + return HttpResponse.json({ + connectorId: "confluence", + sources: [{ ...space, enabled: false }], + }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Engineering")).toBeInTheDocument(); + }); + + // Enabling and disabling is all this modal does — no connecting, no syncing. + expect(screen.queryByRole("button", { name: /add space/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /sync now/i })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /exclude engineering/i })); + await user.click(screen.getByRole("button", { name: /save 1 change/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /include engineering/i })).toBeInTheDocument(); + }); + + // Confluence sources belong to one project, so the backend rejects a patch + // that does not name it. + expect(patchUrl).toContain("projectId=proj-1"); + }); }); diff --git a/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx b/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx new file mode 100644 index 00000000..46a4ec27 --- /dev/null +++ b/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ConfluenceConnectStep } from "../../../../src/features/data-ingestion/components/ConfluenceConnectStep"; +import type { AtlassianCredentialDto } from "../../../../src/services/sources/atlassianService"; + +const credentials: AtlassianCredentialDto[] = [ + { userEmail: "user@test.com", displayName: "default" }, + { userEmail: "other@test.com", displayName: "ci" }, +]; + +describe("ConfluenceConnectStep (data-ingestion)", () => { + it("renders form fields and handles input changes", async () => { + const user = userEvent.setup(); + const onBaseUrlChange = vi.fn(); + const onSpaceIdChange = vi.fn(); + const onCredentialNameChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + , + ); + + const baseUrlInput = screen.getByLabelText(/confluence base url/i); + const spaceIdInput = screen.getByLabelText(/space id/i); + + expect(baseUrlInput).toBeInTheDocument(); + expect(spaceIdInput).toBeInTheDocument(); + expect(screen.getByLabelText("Credential")).toBeInTheDocument(); + + await user.type(baseUrlInput, "https://test.atlassian.net"); + expect(onBaseUrlChange).toHaveBeenCalled(); + + await user.type(spaceIdInput, "123456"); + expect(onSpaceIdChange).toHaveBeenCalled(); + + await user.click(screen.getByLabelText("Credential")); + await user.click(await screen.findByRole("option", { name: "ci - other@test.com" })); + expect(onCredentialNameChange).toHaveBeenCalledWith("ci"); + }); + + it("shows a warning when no credentials are stored", () => { + render( + , + ); + + expect(screen.getByText(/no atlassian credentials are stored/i)).toBeInTheDocument(); + expect(screen.getByLabelText("Credential")).toBeDisabled(); + }); + + it("suppresses the missing-credential banner when asked", () => { + render( + , + ); + + expect(screen.queryByText(/no atlassian credentials are stored/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx index 9f419cc3..863bd6aa 100644 --- a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx +++ b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx @@ -70,8 +70,8 @@ function sourceStatusHandler({ inThisProject = [] as string[] } = {}) { }); } -function jiraCredentialsHandler(names: string[], email = "me@corp.com") { - return http.get("/api/v1/jira/credentials", () => +function atlassianCredentialsHandler(names: string[], email = "me@corp.com") { + return http.get("/api/v1/atlassian/credentials", () => HttpResponse.json(names.map((displayName) => ({ userEmail: email, displayName }))), ); } @@ -109,7 +109,7 @@ describe("AddSourceModal", () => { }); it("offers both 'Add to list' and 'Connect now' on a detail screen", async () => { - server.use(jiraCredentialsHandler(["default"])); + server.use(atlassianCredentialsHandler(["default"])); const user = userEvent.setup(); renderModal(); @@ -127,7 +127,7 @@ describe("AddSourceModal", () => { it("stages a Jira instance and connects the list", async () => { let capturedBody: unknown = null; server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", async ({ request }) => { capturedBody = await request.json(); return new HttpResponse(null, { status: 202 }); @@ -163,7 +163,7 @@ describe("AddSourceModal", () => { it("connects a source immediately with 'Connect now'", async () => { let capturedBody: unknown = null; server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", async ({ request }) => { capturedBody = await request.json(); return new HttpResponse(null, { status: 202 }); @@ -192,7 +192,7 @@ describe("AddSourceModal", () => { it("surfaces a failed connect on the connecting screen with a retry", async () => { server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", () => HttpResponse.json({ message: "bad gateway" }, { status: 502 }), ), @@ -350,7 +350,7 @@ describe("AddSourceModal", () => { }); it("keeps building the list across source types before connecting", async () => { - server.use(discoveryHandler, jiraCredentialsHandler(["default"])); + server.use(discoveryHandler, atlassianCredentialsHandler(["default"])); const user = userEvent.setup(); renderModal(); diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx index 2b6e50de..3c1e5991 100644 --- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx +++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx @@ -1,4 +1,4 @@ -import { render as rtlRender, screen, waitFor } from "@testing-library/react"; +import { render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GitBranch } from "lucide-react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -70,6 +70,23 @@ const jiraSource: DataSource = { }, }; +const confluenceSource: DataSource = { + ...mockSource, + sourceId: "https://acme.atlassian.net|123456", + sourceSystem: "CONFLUENCE", + name: "Engineering", + type: "Confluence", + githubRepository: null, + confluenceSpace: { + connectionId: "conn-1", + baseUrl: "https://acme.atlassian.net", + spaceId: "123456", + spaceKey: "ENG", + spaceName: "Engineering", + credentialName: "default", + }, +}; + describe("SourceDetailsPanel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -181,6 +198,87 @@ describe("SourceDetailsPanel", () => { }); }); + it("renders the Confluence sync schedule and loads it for the connection", async () => { + const onLoadConfluenceConfig = vi.fn().mockResolvedValue({ + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 30 }, + nextSyncAt: null, + }); + + render( + , + ); + + expect(screen.getByText("Sync Schedule")).toBeInTheDocument(); + await waitFor(() => { + expect(onLoadConfluenceConfig).toHaveBeenCalledWith("conn-1"); + }); + }); + + it("saves the Confluence sync schedule via onSaveConfluenceConfig", async () => { + const user = userEvent.setup(); + const onLoadConfluenceConfig = vi.fn().mockResolvedValue({ + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 30 }, + nextSyncAt: null, + }); + const onSaveConfluenceConfig = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await waitFor(() => expect(onLoadConfluenceConfig).toHaveBeenCalled()); + + const minutes = screen.getByLabelText("Minutes"); + await user.clear(minutes); + await user.type(minutes, "45"); + await user.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(onSaveConfluenceConfig).toHaveBeenCalledWith( + "conn-1", + expect.objectContaining({ + autoUpdate: true, + schedule: { type: "INTERVAL", everyMinutes: 45 }, + }), + ); + }); + }); + + it("leaves the update toasts to the caller for a Confluence space", async () => { + const user = userEvent.setup(); + const onUpdateSource = vi.fn().mockRejectedValue(new Error("Confluence sync failed")); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /Update space/ })); + + await waitFor(() => expect(onUpdateSource).toHaveBeenCalledWith(confluenceSource)); + // The Confluence sync reports its own outcome, so neither the success nor + // the failure copy of this panel may appear. + expect(screen.queryByText("Update started")).not.toBeInTheDocument(); + expect(screen.queryByText("Confluence sync failed")).not.toBeInTheDocument(); + }); + it("renders repository and ingestion details", () => { render(); @@ -290,6 +388,33 @@ describe("SourceDetailsPanel", () => { expect(onUnlinkSource).toHaveBeenCalledWith(jiraSource); }); + it("unlinks a Confluence space after confirming the dialog", async () => { + const user = userEvent.setup(); + const onUnlinkSource = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /Remove from project/ })); + + // The Confluence copy calls it a "space", and it must not promise a + // re-link: removing the connection deletes it. + const dialog = screen.getByRole("alertdialog", { name: /Remove space from project/ }); + expect( + within(dialog).getByText(/Connecting the space again sets it up from scratch/), + ).toBeVisible(); + expect(screen.queryByText(/re-link it later/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^Remove$/ })); + + expect(onUnlinkSource).toHaveBeenCalledWith(confluenceSource); + }); + it("surfaces the error message when unlinking fails", async () => { const user = userEvent.setup(); const onUnlinkSource = vi.fn().mockRejectedValue(new Error("You cannot access this project.")); diff --git a/tests/unit/features/data-ingestion/data.test.ts b/tests/unit/features/data-ingestion/data.test.ts index 7ba4858b..4f7b2b1c 100644 --- a/tests/unit/features/data-ingestion/data.test.ts +++ b/tests/unit/features/data-ingestion/data.test.ts @@ -5,6 +5,10 @@ import { INGESTION_RUN_LIMIT, DETAILS_RUN_LIMIT, createJiraSourceFromInstance, + createConfluenceSourceFromConnection, + createConfluenceSourceFromInstance, + buildRunSourceLabels, + getRunSourceLabel, deriveConnectionStatus, deriveSyncStatus, getSourceStatus, @@ -19,15 +23,17 @@ import { } from "../../../../src/features/data-ingestion/data"; import type { ConnectionStatus, + IngestionRun, IngestionRunStatus, SourceInstanceIngestionStatus, } from "../../../../src/features/data-ingestion/types"; import type { JiraInstanceDto } from "../../../../src/services/sources/jiraService"; +import type { ConfluenceConnectionDto } from "../../../../src/services/sources/confluenceService"; describe("data-ingestion data helpers", () => { describe("SOURCE_SYSTEMS / SOURCE_META", () => { it("lists all known source systems", () => { - expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD"]); + expect(SOURCE_SYSTEMS).toEqual(["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]); }); it("provides meta for every source system", () => { @@ -376,4 +382,165 @@ describe("data-ingestion data helpers", () => { expect(deriveSyncStatus(source).label).toBe("Not synced"); }); }); + + describe("buildRunSourceLabels and getRunSourceLabel with Confluence", () => { + const confluenceConn: ConfluenceConnectionDto = { + id: "conn-uuid-1", + projectId: "proj-1", + baseUrl: "https://myteam.atlassian.net", + spaceId: "123456", + spaceKey: "DOCS", + spaceName: null, + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: "2026-08-28T10:00:00Z", + updatedAt: "2026-08-28T10:00:00Z", + version: 1, + sourceEnabled: true, + }; + + it("resolves Confluence runs by composite baseUrl|spaceId, spaceKey, and connectionId", () => { + const source = createConfluenceSourceFromConnection(confluenceConn); + const labels = buildRunSourceLabels([source]); + + expect(labels.get("conn-uuid-1")).toBe("DOCS"); + expect(labels.get("https://myteam.atlassian.net|123456")).toBe("DOCS"); + expect(labels.get("https://myteam.atlassian.net|123456".toLowerCase())).toBe("DOCS"); + expect(labels.get("DOCS")).toBe("DOCS"); + + const runWithCompositeRef: IngestionRun = { + runId: "run-1", + sourceSystem: "CONFLUENCE", + sourceId: "https://myteam.atlassian.net|123456", + owner: null, + name: null, + repositoryId: "conn-uuid-1", + startedAt: "2026-08-28T10:00:00Z", + finishedAt: "2026-08-28T10:05:00Z", + ingestedCount: 10, + updatedCount: 2, + deletedCount: 0, + failedCount: 0, + status: "COMPLETED", + failedItems: [], + failureReason: null, + aiSyncStatus: "SUCCEEDED", + aiSyncFailureReason: null, + }; + + expect(getRunSourceLabel(runWithCompositeRef, labels)).toBe("DOCS"); + }); + + it("matches Confluence runs in createConfluenceSourceFromConnection using repositoryId", () => { + const run: IngestionRun = { + runId: "run-2", + sourceSystem: "CONFLUENCE", + sourceId: "https://different-ref|9999", + owner: null, + name: null, + repositoryId: "conn-uuid-1", + startedAt: "2026-08-28T10:00:00Z", + finishedAt: "2026-08-28T10:05:00Z", + ingestedCount: 5, + updatedCount: 1, + deletedCount: 0, + failedCount: 0, + status: "COMPLETED", + failedItems: [], + failureReason: null, + aiSyncStatus: "SUCCEEDED", + aiSyncFailureReason: null, + }; + + const source = createConfluenceSourceFromConnection(confluenceConn, [run]); + expect(source.ingestionStatusLabel).toBe("Synced"); + expect(source.artifacts).toBe(6); + }); + + it("names the card from spaceName, falling back to spaceKey when there is none", () => { + expect( + createConfluenceSourceFromConnection({ ...confluenceConn, spaceName: "Docs Space" }).name, + ).toBe("Docs Space"); + expect( + createConfluenceSourceFromConnection({ ...confluenceConn, spaceName: null }).name, + ).toBe("DOCS"); + }); + + it("carries spaceName and credentialName onto the source's confluenceSpace details", () => { + const source = createConfluenceSourceFromConnection({ + ...confluenceConn, + spaceName: "Docs Space", + credentialName: "team-cred", + }); + + expect(source.confluenceSpace?.spaceName).toBe("Docs Space"); + expect(source.confluenceSpace?.credentialName).toBe("team-cred"); + }); + + it("creates Confluence source from status instance", () => { + const status: SourceInstanceIngestionStatus = { + sourceSystem: "CONFLUENCE", + sourceId: "https://myteam.atlassian.net|123456", + displayName: "DOCS", + repositoryId: null, + owner: null, + name: null, + sourceUrl: "https://myteam.atlassian.net/wiki/spaces/DOCS", + connectionStatus: "CONNECTED", + enabled: true, + lastRunTime: "2026-08-28T10:00:00Z", + ingestedCount: 10, + updatedCount: 2, + deletedCount: 0, + failedCount: 0, + failedItems: [], + artifactCount: 12, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: null, + }; + + const source = createConfluenceSourceFromInstance(status, confluenceConn); + expect(source.sourceId).toBe("conn-uuid-1"); + expect(source.sourceSystem).toBe("CONFLUENCE"); + expect(source.name).toBe("DOCS"); + expect(source.ingestionStatusLabel).toBe("Synced"); + expect(source.artifacts).toBe(12); + }); + + it("keeps spaceName and credentialName once a status row takes over the card", () => { + const status: SourceInstanceIngestionStatus = { + sourceSystem: "CONFLUENCE", + sourceId: "https://myteam.atlassian.net|123456", + displayName: "Docs Space", + repositoryId: null, + owner: null, + name: null, + sourceUrl: "https://myteam.atlassian.net/wiki/spaces/DOCS", + connectionStatus: "CONNECTED", + enabled: true, + lastRunTime: "2026-08-28T10:00:00Z", + ingestedCount: 10, + updatedCount: 2, + deletedCount: 0, + failedCount: 0, + failedItems: [], + artifactCount: 12, + lastCommitsSyncAt: null, + lastIssuesSyncAt: null, + lastPullRequestsSyncAt: null, + }; + + const source = createConfluenceSourceFromInstance(status, { + ...confluenceConn, + spaceName: "Docs Space", + credentialName: "team-cred", + }); + + expect(source.confluenceSpace?.spaceName).toBe("Docs Space"); + expect(source.confluenceSpace?.credentialName).toBe("team-cred"); + }); + }); }); diff --git a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx index c7fcbb40..1d2d3f7f 100644 --- a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx +++ b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx @@ -315,6 +315,50 @@ describe("ArtifactViewerDrawer", () => { expect(await screen.findByText("Bug Report")).toBeInTheDocument(); }); + it("renders Confluence page artifacts as markdown even with text/plain mime type", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ + content: "## Meeting Notes\n- Attendees\n- Goals", + mimeType: "text/plain", + isObjectUrl: false, + }); + + renderDrawer( + createArtifact({ + title: "Vorlage: Besprechungsnotizen", + artifactType: "PAGE", + sourceSystem: "CONFLUENCE", + sourceUrl: "https://company.atlassian.net/wiki/spaces/SPACE/pages/123", + }), + ); + + const rawContent = await screen.findByTestId("raw-content"); + expect(rawContent.querySelector(".prose")).toBeInTheDocument(); + expect(await screen.findByText("Meeting Notes")).toBeInTheDocument(); + }); + + it("does not render a PAGE artifact from a non-Confluence source as markdown", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ + content: "## Should NOT be markdown", + mimeType: "text/plain", + isObjectUrl: false, + }); + + renderDrawer( + createArtifact({ + title: "Some Page", + artifactType: "PAGE", + sourceSystem: "GITHUB", // not CONFLUENCE, not a /wiki/spaces/ URL + sourceUrl: "https://github.com/org/repo/blob/main/page.txt", + }), + ); + + const rawContent = await screen.findByTestId("raw-content"); + // Should be rendered in the syntax highlighter, not in the .prose markdown container. + expect(rawContent.querySelector(".prose")).not.toBeInTheDocument(); + }); + it("gracefully handles KaTeX math parse errors without breaking the drawer", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ @@ -402,6 +446,31 @@ describe("ArtifactViewerDrawer", () => { expect(codeElement).toBeInTheDocument(); }); + it("uses markdown language when switching to source mode for Confluence page artifacts", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ + content: "## Meeting Notes", + mimeType: "text/plain", + isObjectUrl: false, + }); + + renderDrawer( + createArtifact({ + title: "Meeting Notes", + artifactType: "PAGE", + sourceSystem: "CONFLUENCE", + sourceUrl: "https://company.atlassian.net/wiki/spaces/SPACE/pages/123", + }), + ); + + const sourceBtn = await screen.findByTestId("view-source-btn"); + await userEvent.click(sourceBtn); + + const rawContent = await screen.findByTestId("raw-content"); + const codeElement = rawContent.querySelector("code[class*='language-markdown']"); + expect(codeElement).toBeInTheDocument(); + }); + it("does not generate duplicate line id attributes on inline code elements", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); vi.mocked(knowledgeService.getArtifactContent).mockResolvedValueOnce({ diff --git a/tests/unit/features/onboarding-metrics/OnboardingMetricsPage.filter.test.tsx b/tests/unit/features/onboarding-metrics/OnboardingMetricsPage.filter.test.tsx index 082fa94d..a64b06ca 100644 --- a/tests/unit/features/onboarding-metrics/OnboardingMetricsPage.filter.test.tsx +++ b/tests/unit/features/onboarding-metrics/OnboardingMetricsPage.filter.test.tsx @@ -53,7 +53,13 @@ const metrics = { stalledCount: 1, waitingOnResponseCount: 0, hires: [ - hire({ userId: "a", displayName: "Ada", githubLogin: "ada", stalled: true, stalledReason: "x" }), + hire({ + userId: "a", + displayName: "Ada", + githubLogin: "ada", + stalled: true, + stalledReason: "x", + }), hire({ userId: "b", displayName: "Bob", githubLogin: "bob" }), // Healthy active contributor: has open work in flight but already gets // responses. Must NOT count as needing attention (the old criterion wrongly diff --git a/tests/unit/features/settings/useJiraCredentials.test.ts b/tests/unit/features/settings/useAtlassianCredentials.test.ts similarity index 59% rename from tests/unit/features/settings/useJiraCredentials.test.ts rename to tests/unit/features/settings/useAtlassianCredentials.test.ts index de6de463..a891739e 100644 --- a/tests/unit/features/settings/useJiraCredentials.test.ts +++ b/tests/unit/features/settings/useAtlassianCredentials.test.ts @@ -1,47 +1,47 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; -import { useJiraCredentials } from "../../../../src/features/settings/hooks/useJiraCredentials"; -import type { JiraCredentialsDto } from "../../../../src/services/sources/jiraService"; +import { useAtlassianCredentials } from "../../../../src/features/settings/hooks/useAtlassianCredentials"; +import type { AtlassianCredentialDto } from "../../../../src/services/sources/atlassianService"; -vi.mock("../../../../src/services/sources/jiraService", () => ({ - getMyJiraCredentials: vi.fn(), +vi.mock("../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), })); -import { getMyJiraCredentials } from "../../../../src/services/sources/jiraService"; +import { getMyAtlassianCredentials } from "../../../../src/services/sources/atlassianService"; -const cred = (displayName: string): JiraCredentialsDto => ({ +const cred = (displayName: string): AtlassianCredentialDto => ({ userEmail: "a@b.com", displayName, }); -describe("useJiraCredentials", () => { +describe("useAtlassianCredentials", () => { beforeEach(() => { vi.clearAllMocks(); }); it("loads the authenticated user's credentials on mount", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([cred("default"), cred("ci")]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([cred("default"), cred("ci")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.credentials.map((c) => c.displayName)).toEqual(["default", "ci"]); expect(result.current.error).toBeNull(); - expect(getMyJiraCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); + expect(getMyAtlassianCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); }); it("settles into a loaded-empty state without fetching when disabled", async () => { - const { result } = renderHook(() => useJiraCredentials(false)); + const { result } = renderHook(() => useAtlassianCredentials(false)); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.credentials).toEqual([]); - expect(getMyJiraCredentials).not.toHaveBeenCalled(); + expect(getMyAtlassianCredentials).not.toHaveBeenCalled(); }); it("surfaces an error message when loading fails", async () => { - vi.mocked(getMyJiraCredentials).mockRejectedValue(new Error("Network down")); + vi.mocked(getMyAtlassianCredentials).mockRejectedValue(new Error("Network down")); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.error).toBe("Network down"); @@ -49,11 +49,11 @@ describe("useJiraCredentials", () => { }); it("reloads credentials via reload", async () => { - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("a")]) .mockResolvedValueOnce([cred("a"), cred("b")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.credentials.map((c) => c.displayName)).toEqual(["a"]), @@ -69,15 +69,15 @@ describe("useJiraCredentials", () => { }); it("a slow stale fetch does not overwrite a newer one", async () => { - let resolveSlow: (list: JiraCredentialsDto[]) => void = () => {}; - const slow = new Promise((resolve) => { + let resolveSlow: (list: AtlassianCredentialDto[]) => void = () => {}; + const slow = new Promise((resolve) => { resolveSlow = resolve; }); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockReturnValueOnce(slow) .mockResolvedValueOnce([cred("fresh")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.isRefreshing).toBe(true)); await act(async () => { diff --git a/tests/unit/features/starter-work/CorpusIssueBrowser.test.tsx b/tests/unit/features/starter-work/CorpusIssueBrowser.test.tsx index f353ef07..5fc53603 100644 --- a/tests/unit/features/starter-work/CorpusIssueBrowser.test.tsx +++ b/tests/unit/features/starter-work/CorpusIssueBrowser.test.tsx @@ -89,9 +89,7 @@ describe("CorpusIssueBrowser", () => { vi.spyOn(starterWorkService, "fetchCandidates").mockResolvedValue([]); render(); - expect( - await screen.findByText(/a second way in besides mining/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/a second way in besides mining/i)).toBeInTheDocument(); }); it("hides issues somebody is on by default, and counts them rather than swallowing them", async () => { diff --git a/tests/unit/pages/DataIngestionPage.test.tsx b/tests/unit/pages/DataIngestionPage.test.tsx index 836ee993..ff2b5392 100644 --- a/tests/unit/pages/DataIngestionPage.test.tsx +++ b/tests/unit/pages/DataIngestionPage.test.tsx @@ -2,6 +2,8 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter, useLocation } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "../setup/vitest.setup"; import { DataIngestionPage } from "../../../src/pages/DataIngestionPage"; import { createProjectContextValue, createSelectableProject } from "../setup/projectContext"; @@ -995,6 +997,76 @@ describe("DataIngestionPage", () => { expect(mockConfigureAllGithubRepositories).not.toHaveBeenCalled(); }); + it("applies the global sync schedule to every Confluence connection", async () => { + mockGetAccessibleProject.mockResolvedValue({ + id: "proj1", + name: "Project Alpha", + description: "", + manager: null, + sources: [], + users: [], + }); + + const scheduleRequests: unknown[] = []; + server.use( + http.get("/api/v1/confluence/projects/:projectId/connections", () => + HttpResponse.json([ + { + id: "conn-1", + projectId: "proj1", + baseUrl: "https://acme.atlassian.net", + spaceId: "123456", + spaceKey: "ENG", + spaceName: "Engineering", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + version: 1, + sourceEnabled: true, + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 60 }, + schedule: "every 60m", + nextSyncAt: null, + }, + ]), + ), + http.put( + "/api/v1/confluence/projects/:projectId/connections/:connectionId/schedule", + async ({ request, params }) => { + scheduleRequests.push({ connectionId: params.connectionId, body: await request.json() }); + return HttpResponse.json({}); + }, + ), + ); + + const user = userEvent.setup(); + render( + + + , + ); + + const manageButton = await screen.findByRole("button", { name: /manage sync settings/i }); + await user.click(manageButton); + + expect(await screen.findByText("Confluence Sync Settings")).toBeInTheDocument(); + + await user.click(screen.getByRole("switch", { name: /toggle global confluence auto update/i })); + await user.click(screen.getByRole("button", { name: /apply globally/i })); + + await waitFor(() => { + expect(scheduleRequests).toEqual([ + { + connectionId: "conn-1", + body: { autoUpdate: false, schedule: { type: "INTERVAL", everyMinutes: 60 } }, + }, + ]); + }); + }); + it("opens the connectors modal from Manage connectors", async () => { const user = userEvent.setup(); render( diff --git a/tests/unit/services/atlassianService.test.ts b/tests/unit/services/atlassianService.test.ts new file mode 100644 index 00000000..84db65d9 --- /dev/null +++ b/tests/unit/services/atlassianService.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../setup/vitest.setup"; +import { + addAtlassianCredential, + changeAtlassianCredentialName, + changeAtlassianCredentialToken, + deleteAtlassianCredential, + getMyAtlassianCredentials, +} from "../../../src/services/sources/atlassianService"; + +describe("atlassianService credential endpoints", () => { + it("addAtlassianCredential posts the credential payload", async () => { + expect.assertions(1); + + server.use( + http.post("/api/v1/atlassian/credentials", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + authToken: "secret", + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await addAtlassianCredential({ + userEmail: "pm@example.com", + tokenName: "token-a", + authToken: "secret", + }); + }); + + it("getMyAtlassianCredentials lists the authenticated user's credentials", async () => { + server.use( + http.get("/api/v1/atlassian/credentials", () => + HttpResponse.json([{ userEmail: "pm+user@example.com", displayName: "token-a" }]), + ), + ); + + const credentials = await getMyAtlassianCredentials(); + + expect(credentials).toHaveLength(1); + expect(credentials[0]).toEqual({ + userEmail: "pm+user@example.com", + displayName: "token-a", + }); + }); + + it("deleteAtlassianCredential sends a DELETE with the credential identity", async () => { + expect.assertions(1); + + server.use( + http.delete("/api/v1/atlassian/credentials", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await deleteAtlassianCredential({ + userEmail: "pm@example.com", + tokenName: "token-a", + }); + }); + + it("deleteAtlassianCredential rejects with an ApiError on 404", async () => { + server.use( + http.delete("/api/v1/atlassian/credentials", () => + HttpResponse.json({ message: "unknown credential" }, { status: 404 }), + ), + ); + + await expect( + deleteAtlassianCredential({ userEmail: "pm@example.com", tokenName: "gone" }), + ).rejects.toThrow(); + }); + + it("changeAtlassianCredentialName patches the name and returns the credential", async () => { + server.use( + http.patch("/api/v1/atlassian/credentials/patch/name", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + oldName: "token-a", + newName: "token-b", + }); + + return HttpResponse.json({ + userEmail: "pm@example.com", + displayName: "token-b", + }); + }), + ); + + const credential = await changeAtlassianCredentialName({ + userEmail: "pm@example.com", + oldName: "token-a", + newName: "token-b", + }); + + expect(credential.displayName).toBe("token-b"); + }); + + it("changeAtlassianCredentialToken patches the token secret", async () => { + server.use( + http.patch("/api/v1/atlassian/credentials/patch/token", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + newToken: "new-secret", + }); + + return HttpResponse.json({ + userEmail: "pm@example.com", + displayName: "token-a", + }); + }), + ); + + const credential = await changeAtlassianCredentialToken({ + userEmail: "pm@example.com", + tokenName: "token-a", + newToken: "new-secret", + }); + + expect(credential.displayName).toBe("token-a"); + }); +}); diff --git a/tests/unit/services/confluenceService.test.ts b/tests/unit/services/confluenceService.test.ts new file mode 100644 index 00000000..0c4358c2 --- /dev/null +++ b/tests/unit/services/confluenceService.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../setup/vitest.setup"; +import { + confluenceService, + type ConfluenceConnectionDto, + type ConfluenceIngestionResult, +} from "../../../src/services/sources/confluenceService"; + +const mockConnection: ConfluenceConnectionDto = { + id: "conn-123", + projectId: "proj-1", + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "SPACE-1", + spaceKey: "SP", + spaceName: "Sprint Planning", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + version: 1, + sourceEnabled: true, +}; + +describe("confluenceService", () => { + it("createConnection sends POST with correct payload", async () => { + expect.assertions(2); + + server.use( + http.post("/api/v1/confluence/projects/proj-1/connections", async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "SPACE-1", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + }); + + return HttpResponse.json(mockConnection); + }), + ); + + const result = await confluenceService.createConnection("proj-1", { + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "SPACE-1", + credentialName: "default", + }); + + expect(result).toEqual(mockConnection); + }); + + it("listConnections calls GET /api/v1/confluence/projects/:projectId/connections", async () => { + expect.assertions(2); + + server.use( + http.get("/api/v1/confluence/projects/proj-1/connections", () => { + return HttpResponse.json([mockConnection]); + }), + ); + + const result = await confluenceService.listConnections("proj-1"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("conn-123"); + }); + + it("getConnection calls GET /api/v1/confluence/projects/:projectId/connections/:connectionId", async () => { + expect.assertions(2); + + server.use( + http.get("/api/v1/confluence/projects/proj-1/connections/conn-123", () => { + return HttpResponse.json(mockConnection); + }), + ); + + const result = await confluenceService.getConnection("proj-1", "conn-123"); + expect(result.id).toBe("conn-123"); + expect(result.spaceId).toBe("SPACE-1"); + }); + + it("syncConnection calls POST /api/v1/confluence/projects/:projectId/connections/:connectionId/update", async () => { + expect.assertions(2); + + const mockResult: ConfluenceIngestionResult = { + runId: "run-1", + connectionId: "conn-123", + discovered: 10, + eligible: 10, + filtered: 0, + created: 10, + updated: 0, + unchanged: 0, + failed: 0, + failures: [], + status: "COMPLETED", + }; + + server.use( + http.post("/api/v1/confluence/projects/proj-1/connections/conn-123/update", () => { + return HttpResponse.json(mockResult); + }), + ); + + const result = await confluenceService.syncConnection("proj-1", "conn-123"); + expect(result.status).toBe("COMPLETED"); + expect(result.created).toBe(10); + }); + + it("deleteConnection calls DELETE /api/v1/confluence/projects/:projectId/connections/:connectionId", async () => { + expect.assertions(1); + + server.use( + http.delete("/api/v1/confluence/projects/proj-1/connections/conn-123", ({ request }) => { + expect(request.method).toBe("DELETE"); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await confluenceService.deleteConnection("proj-1", "conn-123"); + }); + + it("configureSchedule calls PUT /api/v1/confluence/projects/:projectId/connections/:connectionId/schedule", async () => { + expect.assertions(2); + + server.use( + http.put( + "/api/v1/confluence/projects/proj-1/connections/conn-123/schedule", + async ({ request }) => { + const body = await request.json(); + expect(body).toEqual({ + autoUpdate: true, + schedule: { type: "INTERVAL", everyMinutes: 60 }, + }); + + return HttpResponse.json({ ...mockConnection, autoUpdate: true }); + }, + ), + ); + + const result = await confluenceService.configureSchedule("proj-1", "conn-123", { + autoUpdate: true, + schedule: { type: "INTERVAL", everyMinutes: 60 }, + }); + expect(result.autoUpdate).toBe(true); + }); +}); diff --git a/tests/unit/services/connectorService.test.ts b/tests/unit/services/connectorService.test.ts index 16dea36f..dc2d3291 100644 --- a/tests/unit/services/connectorService.test.ts +++ b/tests/unit/services/connectorService.test.ts @@ -107,6 +107,26 @@ describe("connectorService", () => { expect(response.sources[0].enabled).toBe(false); }); + it("patchConnectorSources scopes the update to a project when one is given", async () => { + let requestUrl: string | null = null; + + server.use( + http.patch("/api/v1/connectors/confluence/sources/status", ({ request }) => { + requestUrl = request.url; + + return HttpResponse.json({ connectorId: "confluence", sources: [] }); + }), + ); + + await connectorService.patchConnectorSources( + "confluence", + [{ sourceId: "11111111-1111-1111-1111-111111111111", enabled: false }], + "proj-1", + ); + + expect(requestUrl).toContain("projectId=proj-1"); + }); + it("patchConnectorSources rejects with an ApiError on 400", async () => { server.use( http.patch("/api/v1/connectors/github/sources/status", () => diff --git a/tests/unit/services/jiraService.test.ts b/tests/unit/services/jiraService.test.ts index da376514..c619fed9 100644 --- a/tests/unit/services/jiraService.test.ts +++ b/tests/unit/services/jiraService.test.ts @@ -2,16 +2,11 @@ import { describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../setup/vitest.setup"; import { - addJiraCredential, - changeJiraCredentialName, - changeJiraCredentialToken, configureAllJiraInstances, configureJiraInstance, connectJiraInstance, - deleteJiraCredential, getAllJiraConfigs, getJiraConfig, - getMyJiraCredentials, getJiraInstances, removeJiraInstanceFromProject, updateAllJiraInstances, @@ -136,127 +131,6 @@ describe("jiraService instance endpoints", () => { }); }); -describe("jiraService credential endpoints", () => { - it("addJiraCredential posts the credential payload", async () => { - expect.assertions(1); - - server.use( - http.post("/api/v1/jira/credentials", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - authToken: "secret", - }); - - return new HttpResponse(null, { status: 204 }); - }), - ); - - await addJiraCredential({ - userEmail: "pm@example.com", - tokenName: "token-a", - authToken: "secret", - }); - }); - - it("getMyJiraCredentials lists the authenticated user's credentials", async () => { - server.use( - http.get("/api/v1/jira/credentials", () => - HttpResponse.json([{ userEmail: "pm+user@example.com", displayName: "token-a" }]), - ), - ); - - const credentials = await getMyJiraCredentials(); - - expect(credentials).toHaveLength(1); - expect(credentials[0]).toEqual({ - userEmail: "pm+user@example.com", - displayName: "token-a", - }); - }); - it("deleteJiraCredential sends a DELETE with the credential identity", async () => { - expect.assertions(1); - - server.use( - http.delete("/api/v1/jira/credentials", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - }); - - return new HttpResponse(null, { status: 204 }); - }), - ); - - await deleteJiraCredential({ - userEmail: "pm@example.com", - tokenName: "token-a", - }); - }); - - it("deleteJiraCredential rejects with an ApiError on 404", async () => { - server.use( - http.delete("/api/v1/jira/credentials", () => - HttpResponse.json({ message: "unknown credential" }, { status: 404 }), - ), - ); - - await expect( - deleteJiraCredential({ userEmail: "pm@example.com", tokenName: "gone" }), - ).rejects.toThrow(); - }); - - it("changeJiraCredentialName patches the name and returns the credential", async () => { - server.use( - http.patch("/api/v1/jira/credentials/patch/name", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - oldName: "token-a", - newName: "token-b", - }); - - return HttpResponse.json({ - userEmail: "pm@example.com", - displayName: "token-b", - }); - }), - ); - - const credential = await changeJiraCredentialName({ - userEmail: "pm@example.com", - oldName: "token-a", - newName: "token-b", - }); - - expect(credential.displayName).toBe("token-b"); - }); - - it("changeJiraCredentialToken patches the token secret", async () => { - server.use( - http.patch("/api/v1/jira/credentials/patch/token", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - newToken: "new-secret", - }); - - return HttpResponse.json({ - userEmail: "pm@example.com", - displayName: "token-a", - }); - }), - ); - - const credential = await changeJiraCredentialToken({ - userEmail: "pm@example.com", - tokenName: "token-a", - newToken: "new-secret", - }); - - expect(credential.displayName).toBe("token-a"); - }); -}); - describe("jiraService config endpoints", () => { it("getAllJiraConfigs lists every instance config", async () => { server.use( diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts index f046d71d..981e075a 100644 --- a/tests/unit/setup/msw-handlers.ts +++ b/tests/unit/setup/msw-handlers.ts @@ -117,7 +117,7 @@ export const handlers = [ // management view mounts every connector at once, so both are needed even in // tests that are not about credentials at all. http.get("/api/v1/github/pat", () => HttpResponse.json([])), - http.get("/api/v1/jira/credentials", () => HttpResponse.json([])), + http.get("/api/v1/atlassian/credentials", () => HttpResponse.json([])), http.get("/api/v1/projects/:projectId/artifacts", () => HttpResponse.json({ @@ -521,4 +521,88 @@ export const handlers = [ }), http.post("/api/v1/users/:userId/project-roles", () => new HttpResponse(null, { status: 200 })), + + http.get("/api/v1/confluence/projects/:projectId/connections", () => HttpResponse.json([])), + http.post("/api/v1/confluence/projects/:projectId/connections", () => + HttpResponse.json({ + id: "conn-default", + projectId: "proj-1", + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "123456", + spaceKey: "EX", + spaceName: "Example Space", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + sourceEnabled: true, + }), + ), + http.post("/api/v1/confluence/projects/:projectId/connections/:connectionId/update", () => + HttpResponse.json({ + runId: "run-default", + connectionId: "conn-default", + discovered: 0, + eligible: 0, + filtered: 0, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + failures: [], + status: "COMPLETED", + }), + ), + http.get("/api/v1/confluence/projects/:projectId/connections/:connectionId", ({ params }) => + HttpResponse.json({ + id: params.connectionId, + projectId: params.projectId, + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "123456", + spaceKey: "EX", + spaceName: "Example Space", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + sourceEnabled: true, + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 60 }, + schedule: "every 60m", + nextSyncAt: null, + }), + ), + http.put( + "/api/v1/confluence/projects/:projectId/connections/:connectionId/schedule", + ({ params }) => + HttpResponse.json({ + id: params.connectionId, + projectId: params.projectId, + baseUrl: "https://example.atlassian.net/wiki", + spaceId: "123456", + spaceKey: "EX", + spaceName: "Example Space", + credentialName: "default", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 2, + sourceEnabled: true, + autoUpdate: true, + spec: { type: "INTERVAL", everyMinutes: 60 }, + schedule: "every 60m", + nextSyncAt: null, + }), + ), + http.get("/api/v1/connectors/confluence/sources", () => + HttpResponse.json({ connectorId: "confluence", sources: [] }), + ), ];