Files
resolutionflow/frontend/src/lib/sessionShare.test.ts
chihlasm 57f429f33b feat: session sharing frontend (#76)
* feat: add session sharing types, API client, and utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add SessionTimeline and ActionMenu reusable components

SessionTimeline extracts timeline/checklist rendering from SessionDetailPage
into a reusable component for both authenticated and public session views.
ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ShareSessionModal and integrate into SessionDetailPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Share Progress popover to TreeNavigationPage

Replace the single "Copy for Ticket" button with a "Share Progress"
popover that offers three actions: Copy Progress Summary (existing PSA
export flow), Copy Share Link (auto-creates account-only share if
needed), and Manage Share Links (opens ShareSessionModal).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add public SharedSessionPage with tree preview

Add the public-facing shared session page at /share/:shareToken that
renders shared sessions without authentication. Includes error handling
for 401 (redirect to login), 403 (access denied), 404 (not found),
and 410 (expired). The page features a minimal header, session metadata,
SessionTimeline component, and a new SharedSessionTreePreview component
that renders the decision tree structure with the path taken highlighted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add My Shares management page with nav link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review issues in session sharing

- Add useCallback for loadShares in ShareSessionModal (React hook deps)
- Use TreeStructure type instead of Record<string, unknown> for type safety
- Fix login redirect format to match LoginPage's expected state shape

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add focused tests for session sharing utilities and API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve tree_structure type compatibility for shared session views

- Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility
- Add explicit cast in SharedSessionTreePreview for recursive node rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add session sharing learnings to CLAUDE.md

Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect
state format), note about npm run build strictness, and public route
pattern to Common Tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: procedural editor UX improvements

Add URL intake field type, fix variable name editing collapsing fields
(index-based keys/updates), auto-generate variable names by field type,
add section header as first-class step type, and simplify step editor
with "More Options" collapsible for advanced fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow section_header step type in validation, improve tag input

- Add 'section_header' to VALID_STEP_TYPES in backend validation so
  procedural flows with section headers can be published
- Replace procedural editor's inline tag input with TagInput component
  (supports autocomplete, Tab, comma, semicolon, and paste splitting)
- Add semicolon delimiter support to TagInput component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add type-aware routing for procedural flows

Centralizes tree navigation routing via getTreeNavigatePath helper.
Fixes all pages to route procedural sessions to /flows/:id/navigate
instead of /trees/:id/navigate. Adds safety redirect in troubleshooting
navigator and resume support in procedural navigator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused index prop from IntakeFieldEditor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:08:17 -05:00

86 lines
3.0 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest'
import type { SessionShare } from '@/types'
import { buildSessionShareUrl, filterSharesForSession, getLatestActiveShareForSession } from './sessionShare'
function makeMockShare(overrides: Partial<SessionShare> = {}): SessionShare {
return {
id: 'share-1',
session_id: 'session-1',
account_id: 'account-1',
share_token: 'abc123',
share_name: null,
visibility: 'public',
created_by: 'user-1',
created_at: '2026-02-14T10:00:00Z',
updated_at: '2026-02-14T10:00:00Z',
expires_at: null,
view_count: 0,
last_viewed_at: null,
is_active: true,
share_url: null,
...overrides,
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('buildSessionShareUrl', () => {
it('returns share_url when present', () => {
const share = makeMockShare({ share_url: 'https://resolutionflow.com/share/abc123' })
expect(buildSessionShareUrl(share)).toBe('https://resolutionflow.com/share/abc123')
})
it('constructs URL from token when share_url is null', () => {
vi.stubGlobal('location', { origin: 'http://localhost:5173' })
const share = makeMockShare({ share_token: 'tok-xyz', share_url: null })
expect(buildSessionShareUrl(share)).toBe('http://localhost:5173/share/tok-xyz')
})
})
describe('filterSharesForSession', () => {
it('filters to matching session_id and active shares', () => {
const shares = [
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
makeMockShare({ id: 's2', session_id: 'sess-B', is_active: true }),
makeMockShare({ id: 's3', session_id: 'sess-A', is_active: true }),
]
const result = filterSharesForSession(shares, 'sess-A')
expect(result).toHaveLength(2)
expect(result.map(s => s.id)).toEqual(['s1', 's3'])
})
it('excludes inactive shares', () => {
const shares = [
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
makeMockShare({ id: 's2', session_id: 'sess-A', is_active: false }),
]
const result = filterSharesForSession(shares, 'sess-A')
expect(result).toHaveLength(1)
expect(result[0].id).toBe('s1')
})
})
describe('getLatestActiveShareForSession', () => {
it('returns the most recently created share', () => {
const shares = [
makeMockShare({ id: 'old', session_id: 'sess-A', created_at: '2026-02-10T10:00:00Z', is_active: true }),
makeMockShare({ id: 'newest', session_id: 'sess-A', created_at: '2026-02-14T12:00:00Z', is_active: true }),
makeMockShare({ id: 'mid', session_id: 'sess-A', created_at: '2026-02-12T10:00:00Z', is_active: true }),
]
const result = getLatestActiveShareForSession(shares, 'sess-A')
expect(result).not.toBeNull()
expect(result!.id).toBe('newest')
})
it('returns null when no shares match', () => {
const shares = [
makeMockShare({ session_id: 'sess-B', is_active: true }),
makeMockShare({ session_id: 'sess-A', is_active: false }),
]
const result = getLatestActiveShareForSession(shares, 'sess-A')
expect(result).toBeNull()
})
})