@duffcloudservices/cms 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { defineComponent, h } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+ import { useReviewContent, type ReviewItem } from './useReviewContent'
5
+
6
+ /**
7
+ * Honesty guard: consumer-supplied `defaults` (placeholder / sample reviews)
8
+ * are a DEV-ONLY affordance. In a production build the composable must render an
9
+ * honest empty state instead of fabricated social proof — this is the fix for
10
+ * the flagship SSR bug where a fabricated fallback was baked into the crawler
11
+ * HTML while the client showed the real reviews.
12
+ */
13
+
14
+ const SAMPLE_DEFAULTS: ReviewItem[] = [
15
+ { id: 'seed-1', platform: 'google', rating: 5, authorName: 'Sample Person', text: 'Placeholder review' },
16
+ ]
17
+
18
+ function mountReviews(config: Parameters<typeof useReviewContent>[0]) {
19
+ let api!: ReturnType<typeof useReviewContent>
20
+ const Comp = defineComponent({
21
+ setup() {
22
+ api = useReviewContent(config)
23
+ return () => h('div')
24
+ },
25
+ })
26
+ const wrapper = mount(Comp)
27
+ return { api, wrapper }
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllEnvs()
32
+ vi.unstubAllGlobals()
33
+ vi.restoreAllMocks()
34
+ delete (globalThis as Record<string, unknown>).__DCS_CONTENT__
35
+ })
36
+
37
+ describe('useReviewContent honesty guard', () => {
38
+ it('production build with no content define renders an empty state, NOT the defaults', () => {
39
+ vi.stubEnv('DEV', false)
40
+ // __DCS_CONTENT__ intentionally undefined (simulates a production SSR pass
41
+ // where the define never reached the composable / no curated reviews).
42
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
43
+
44
+ expect(api.reviews.value).toEqual([])
45
+ expect(api.hasReviews.value).toBe(false)
46
+ expect(api.count.value).toBe(0)
47
+ wrapper.unmount()
48
+ })
49
+
50
+ it('production build with no reviews for the section renders empty, NOT the defaults', () => {
51
+ vi.stubEnv('DEV', false)
52
+ vi.stubGlobal('__DCS_CONTENT__', { global: {}, pages: {} })
53
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
54
+
55
+ expect(api.reviews.value).toEqual([])
56
+ expect(api.hasReviews.value).toBe(false)
57
+ wrapper.unmount()
58
+ })
59
+
60
+ it('production build DOES render the real curated reviews from the content define', () => {
61
+ vi.stubEnv('DEV', false)
62
+ vi.stubGlobal('__DCS_CONTENT__', {
63
+ global: {
64
+ 'reviews.testimonials.items': [
65
+ { id: 'real-1', platform: 'google', rating: 5, authorName: 'Real Reviewer', text: 'Genuine review' },
66
+ ],
67
+ },
68
+ pages: {},
69
+ })
70
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
71
+
72
+ expect(api.reviews.value).toHaveLength(1)
73
+ expect(api.reviews.value[0].authorName).toBe('Real Reviewer')
74
+ expect(api.hasReviews.value).toBe(true)
75
+ wrapper.unmount()
76
+ })
77
+
78
+ it('dev build still surfaces the sample defaults (DX affordance)', () => {
79
+ vi.stubEnv('DEV', true)
80
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
81
+
82
+ expect(api.reviews.value).toHaveLength(1)
83
+ expect(api.reviews.value[0].id).toBe('seed-1')
84
+ wrapper.unmount()
85
+ })
86
+ })
@@ -25,6 +25,38 @@ function withoutAuthorPhotos(items: ReviewItem[]): ReviewItem[] {
25
25
  }))
26
26
  }
27
27
 
28
+ /**
29
+ * True only in a Vite dev build. Vite statically replaces `import.meta.env.DEV`
30
+ * with a boolean literal (`true` in dev, `false` in production). The
31
+ * `typeof import.meta` guard + try/catch keep this safe if the package is ever
32
+ * SSR-externalized (where `import.meta.env` is undefined at the Node runtime and
33
+ * accessing it would throw) — in that case we deliberately return `false` so the
34
+ * production-safe branch is taken.
35
+ */
36
+ function isDevBuild(): boolean {
37
+ try {
38
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
39
+ return import.meta.env.DEV === true
40
+ }
41
+ } catch {
42
+ // import.meta.env unavailable (externalized SSR / non-Vite host) → prod-safe.
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Honesty guard: consumer-supplied `defaults` are a DEV-ONLY affordance so a
49
+ * freshly-scaffolded site shows sample reviews while it is being wired up. In a
50
+ * production build they must NEVER render — an SSR production pass emitting
51
+ * placeholder reviews is exactly the fabricated-social-proof / hydration bug
52
+ * this guards against (the content define can be missing server-side if the cms
53
+ * package is SSR-externalized). Production therefore falls back to an honest
54
+ * empty state; real reviews always come from `.dcs/content.yaml`.
55
+ */
56
+ function fallbackReviews(defaults: ReviewItem[]): ReviewItem[] {
57
+ return isDevBuild() ? withoutAuthorPhotos(defaults) : []
58
+ }
59
+
28
60
  export interface UseReviewContentConfig {
29
61
  /** The section key matching the data-dcs-reviews attribute value */
30
62
  sectionKey: string
@@ -130,7 +162,7 @@ export function useReviewContent(config: UseReviewContentConfig): UseReviewConte
130
162
  }
131
163
 
132
164
  if (typeof __DCS_CONTENT__ === 'undefined' || __DCS_CONTENT__ == null) {
133
- return withoutAuthorPhotos(defaults)
165
+ return fallbackReviews(defaults)
134
166
  }
135
167
 
136
168
  let reviewData: unknown = null
@@ -146,7 +178,7 @@ export function useReviewContent(config: UseReviewContentConfig): UseReviewConte
146
178
  }
147
179
 
148
180
  if (!reviewData || !Array.isArray(reviewData)) {
149
- return withoutAuthorPhotos(defaults)
181
+ return fallbackReviews(defaults)
150
182
  }
151
183
 
152
184
  return normalizeReviewList(reviewData)
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { fetchSiteVisitorSession } from './useSiteVisitorSession'
3
+
4
+ /**
5
+ * The anonymous session probe is the common case and MUST be silent — no
6
+ * console.error / console.warn — whether the server answers with the new
7
+ * `200 { visitor: null }` contract or an old server's `401`. A red console
8
+ * error on every signed-out page load is the console-noise this fixes.
9
+ */
10
+
11
+ function installConsoleSpies() {
12
+ return {
13
+ error: vi.spyOn(console, 'error').mockImplementation(() => {}),
14
+ warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
15
+ }
16
+ }
17
+
18
+ function expectSilent(spies: ReturnType<typeof installConsoleSpies>) {
19
+ expect(spies.error).not.toHaveBeenCalled()
20
+ expect(spies.warn).not.toHaveBeenCalled()
21
+ }
22
+
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('fetchSiteVisitorSession — anonymous probe is silent', () => {
29
+ it('new server 200 {visitor:null} → signed out, no console noise', async () => {
30
+ const spies = installConsoleSpies()
31
+ vi.stubGlobal(
32
+ 'fetch',
33
+ vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) }),
34
+ )
35
+
36
+ const result = await fetchSiteVisitorSession()
37
+
38
+ expect(result.visitor).toBeNull()
39
+ expect(result.authenticated).toBe(false)
40
+ expectSilent(spies)
41
+ })
42
+
43
+ it('old server 401 → signed out, no console noise', async () => {
44
+ const spies = installConsoleSpies()
45
+ vi.stubGlobal(
46
+ 'fetch',
47
+ vi.fn().mockResolvedValue({
48
+ ok: false,
49
+ status: 401,
50
+ json: async () => ({ error: 'Not authenticated' }),
51
+ }),
52
+ )
53
+
54
+ const result = await fetchSiteVisitorSession()
55
+
56
+ expect(result.visitor).toBeNull()
57
+ expect(result.authenticated).toBe(false)
58
+ expectSilent(spies)
59
+ })
60
+
61
+ it('network error → signed out, no console noise', async () => {
62
+ const spies = installConsoleSpies()
63
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
64
+
65
+ const result = await fetchSiteVisitorSession()
66
+
67
+ expect(result.visitor).toBeNull()
68
+ expect(result.authenticated).toBe(false)
69
+ expectSilent(spies)
70
+ })
71
+ })
72
+
73
+ describe('fetchSiteVisitorSession — authenticated shapes', () => {
74
+ it('200 {visitor:{...}} → returns the visitor', async () => {
75
+ vi.stubGlobal(
76
+ 'fetch',
77
+ vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ status: 200,
80
+ json: async () => ({
81
+ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor', createdAt: '2026-01-01T00:00:00Z' },
82
+ }),
83
+ }),
84
+ )
85
+
86
+ const result = await fetchSiteVisitorSession()
87
+
88
+ expect(result.authenticated).toBe(true)
89
+ expect(result.visitor?.email).toBe('v@example.com')
90
+ expect(result.visitor?.name).toBe('Visitor')
91
+ })
92
+
93
+ it('contracts-spec shape {authenticated,user:{...}} → returns the visitor', async () => {
94
+ vi.stubGlobal(
95
+ 'fetch',
96
+ vi.fn().mockResolvedValue({
97
+ ok: true,
98
+ status: 200,
99
+ json: async () => ({ authenticated: true, user: { email: 'u@example.com', name: 'User' } }),
100
+ }),
101
+ )
102
+
103
+ const result = await fetchSiteVisitorSession()
104
+
105
+ expect(result.authenticated).toBe(true)
106
+ expect(result.visitor?.email).toBe('u@example.com')
107
+ })
108
+
109
+ it('probes the slug-free /site-auth/session path with credentials', async () => {
110
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) })
111
+ vi.stubGlobal('fetch', fetchMock)
112
+
113
+ await fetchSiteVisitorSession()
114
+
115
+ expect(fetchMock).toHaveBeenCalledTimes(1)
116
+ const [url, init] = fetchMock.mock.calls[0]
117
+ expect(url).toBe('/api/v1/site-auth/session')
118
+ expect(init.credentials).toBe('include')
119
+ })
120
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Site-visitor session probe client.
3
+ *
4
+ * Customer sites poll "who is signed in?" on load to render the right auth UI.
5
+ * Signed-out is the EXPECTED state for the vast majority of visits, so this
6
+ * client treats it as normal and never logs:
7
+ *
8
+ * - New servers answer an anonymous probe with `200 { "visitor": null }`.
9
+ * - Old servers (pre-fix) answer an anonymous probe with `401`.
10
+ *
11
+ * Sites bump `@duffcloudservices/cms` independently of server deploys, so this
12
+ * client must accept BOTH shapes and stay silent on the anonymous path — a
13
+ * `console.error`/`console.warn` on every signed-out page load is exactly the
14
+ * console-noise this fixes. A genuine signed-in visitor is returned as a
15
+ * normalized `SiteVisitor`; anything else resolves to `null`.
16
+ */
17
+ import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
18
+
19
+ /** A signed-in site visitor. */
20
+ export interface SiteVisitor {
21
+ id?: string
22
+ email: string
23
+ name: string
24
+ picture?: string
25
+ createdAt?: string
26
+ }
27
+
28
+ export interface SiteVisitorSessionResult {
29
+ /** The signed-in visitor, or `null` when signed out. */
30
+ visitor: SiteVisitor | null
31
+ /** Convenience flag — `true` iff a visitor is present. */
32
+ authenticated: boolean
33
+ }
34
+
35
+ export interface FetchSiteVisitorSessionOptions {
36
+ /** API base (default `/api/v1`, same-origin via Front Door). */
37
+ apiBaseUrl?: string
38
+ /** Optional AbortSignal to cancel an in-flight probe. */
39
+ signal?: AbortSignal
40
+ }
41
+
42
+ const SIGNED_OUT: SiteVisitorSessionResult = { visitor: null, authenticated: false }
43
+
44
+ const apiBase = (value?: string): string => (value ?? '/api/v1').replace(/\/$/u, '')
45
+
46
+ /**
47
+ * Normalize the various session-response shapes into a `SiteVisitor | null`.
48
+ * Accepts the primary/legacy `{ visitor: {...} | null }` envelope and the
49
+ * contracts-spec `{ authenticated, user: {...} }` shape defensively.
50
+ */
51
+ function extractVisitor(data: unknown): SiteVisitor | null {
52
+ if (!data || typeof data !== 'object') {
53
+ return null
54
+ }
55
+ const obj = data as Record<string, unknown>
56
+ const raw = obj.visitor ?? obj.user
57
+ if (!raw || typeof raw !== 'object') {
58
+ return null
59
+ }
60
+ const v = raw as Record<string, unknown>
61
+ const email = typeof v.email === 'string' ? v.email : ''
62
+ if (!email) {
63
+ return null
64
+ }
65
+ return {
66
+ id: typeof v.id === 'string' ? v.id : undefined,
67
+ email,
68
+ name: typeof v.name === 'string' ? v.name : email,
69
+ picture: typeof v.picture === 'string' ? v.picture : undefined,
70
+ createdAt: typeof v.createdAt === 'string' ? v.createdAt : undefined,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch the current site-visitor session. Never throws and never logs for the
76
+ * anonymous case: any network error, non-OK status (incl. a legacy `401`), or
77
+ * `{ visitor: null }` body resolves to a signed-out result.
78
+ */
79
+ export async function fetchSiteVisitorSession(
80
+ options: FetchSiteVisitorSessionOptions = {},
81
+ ): Promise<SiteVisitorSessionResult> {
82
+ const base = apiBase(options.apiBaseUrl)
83
+
84
+ let response: Response
85
+ try {
86
+ response = await fetch(`${base}/site-auth/session`, {
87
+ method: 'GET',
88
+ credentials: 'include',
89
+ headers: { Accept: 'application/json' },
90
+ signal: options.signal,
91
+ })
92
+ } catch {
93
+ // Network error / aborted / CORS — signed-out is the safe probe assumption.
94
+ return SIGNED_OUT
95
+ }
96
+
97
+ // Old servers reply 401 to an anonymous probe; new servers reply 200-null.
98
+ // Treat any non-OK status as signed-out WITHOUT logging.
99
+ if (!response.ok) {
100
+ return SIGNED_OUT
101
+ }
102
+
103
+ let data: unknown
104
+ try {
105
+ data = await response.json()
106
+ } catch {
107
+ return SIGNED_OUT
108
+ }
109
+
110
+ const visitor = extractVisitor(data)
111
+ return { visitor, authenticated: visitor !== null }
112
+ }
113
+
114
+ export interface UseSiteVisitorSessionOptions extends FetchSiteVisitorSessionOptions {
115
+ /** Probe automatically on mount (browser only). Default `true`. */
116
+ fetchOnMount?: boolean
117
+ }
118
+
119
+ export interface UseSiteVisitorSessionReturn {
120
+ /** The signed-in visitor, or `null` when signed out / not yet loaded. */
121
+ visitor: Ref<SiteVisitor | null>
122
+ /** `true` iff a visitor is present. */
123
+ isAuthenticated: ComputedRef<boolean>
124
+ /** `true` while a probe is in flight. */
125
+ isLoading: Ref<boolean>
126
+ /** Re-run the probe. */
127
+ refresh: () => Promise<void>
128
+ }
129
+
130
+ /**
131
+ * Vue composable wrapper around {@link fetchSiteVisitorSession}. Reactive
132
+ * `visitor` / `isAuthenticated` / `isLoading`, and probes on mount by default.
133
+ */
134
+ export function useSiteVisitorSession(
135
+ options: UseSiteVisitorSessionOptions = {},
136
+ ): UseSiteVisitorSessionReturn {
137
+ const { fetchOnMount = true, ...fetchOptions } = options
138
+
139
+ const visitor = ref<SiteVisitor | null>(null)
140
+ const isLoading = ref(false)
141
+ const isAuthenticated = computed(() => visitor.value !== null)
142
+
143
+ async function refresh(): Promise<void> {
144
+ isLoading.value = true
145
+ try {
146
+ const result = await fetchSiteVisitorSession(fetchOptions)
147
+ visitor.value = result.visitor
148
+ } finally {
149
+ isLoading.value = false
150
+ }
151
+ }
152
+
153
+ if (fetchOnMount && typeof window !== 'undefined') {
154
+ onMounted(() => {
155
+ void refresh()
156
+ })
157
+ }
158
+
159
+ return { visitor, isAuthenticated, isLoading, refresh }
160
+ }
@@ -1,3 +0,0 @@
1
-
2
- //# sourceMappingURL=chunk-KCWMS7P4.js.map
3
- //# sourceMappingURL=chunk-KCWMS7P4.js.map