Răsfoiți Sursa

Show the staging app the What's new entries being tried on staging (#596)

On Torqvoice Cloud the app names its own address when it asks torqvoice.com for the highlights, so the staging deployment gets staging entries. A self-hosted install still sends only its language.
Bernt Christian Egeland 6 ore în urmă
părinte
comite
90524c5b23

+ 39 - 0
src/__tests__/features/whats-new/feed-url.test.ts

@@ -0,0 +1,39 @@
+/**
+ * What the app tells torqvoice.com when it asks for the highlights.
+ *
+ * The staging deployment has to say which app it is, or an entry could never
+ * be tried there before it goes live. A self-hosted workshop's address is
+ * nobody's business, so that one says only which language it wants.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('server-only', () => ({}))
+
+let cloud = false
+vi.mock('@/lib/features', () => ({ isCloudMode: () => cloud }))
+
+const { whatsNewFeedUrl } = await import('@/features/whats-new/Lib/feed.server')
+
+beforeEach(() => {
+  vi.stubEnv('TORQVOICE_COM_URL', 'https://site.test')
+  vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://staging.torqvoice.test/some/path')
+})
+afterEach(() => vi.unstubAllEnvs())
+
+describe('asking for the highlights', () => {
+  it('names the deployment on Torqvoice Cloud, by origin only', () => {
+    cloud = true
+    const url = new URL(whatsNewFeedUrl('pt-BR'))
+    expect(`${url.origin}${url.pathname}`).toBe('https://site.test/api/app/whats-new')
+    expect(url.searchParams.get('locale')).toBe('pt-BR')
+    expect(url.searchParams.get('app')).toBe('https://staging.torqvoice.test')
+  })
+
+  it('says nothing about a self-hosted install but its language', () => {
+    cloud = false
+    const url = new URL(whatsNewFeedUrl('nb'))
+    expect([...url.searchParams.keys()]).toEqual(['locale'])
+    expect(url.toString()).not.toContain('staging.torqvoice.test')
+  })
+})

+ 19 - 3
src/features/whats-new/Lib/feed.server.ts

@@ -1,5 +1,6 @@
 import 'server-only'
-import { torqvoiceComUrl } from '@/lib/torqvoice-com'
+import { isCloudMode } from '@/lib/features'
+import { appOrigin, torqvoiceComUrl } from '@/lib/torqvoice-com'
 import { type WhatsNewEntry, whatsNewFeedSchema } from '../Schema/whatsNewSchema'
 import { sanitizeBody } from './sanitizeBody'
 
@@ -15,8 +16,22 @@ type Cached = { entries: WhatsNewEntry[]; expiresAt: number; refreshing?: Promis
 const globalForFeed = globalThis as unknown as { __whatsNewFeed?: Map<string, Cached> }
 const cache = (globalForFeed.__whatsNewFeed ??= new Map())
 
+/**
+ * Where the feed is asked for. A Torqvoice Cloud deployment names its own
+ * address, which is how the staging app is handed the entries being tried
+ * out there before they go live. A self-hosted install says nothing about
+ * itself: its address is the workshop's business, and it gets the same
+ * production entries either way.
+ */
+export function whatsNewFeedUrl(locale: string): string {
+  const url = new URL(`${torqvoiceComUrl()}/api/app/whats-new`)
+  url.searchParams.set('locale', locale)
+  if (isCloudMode()) url.searchParams.set('app', appOrigin())
+  return url.toString()
+}
+
 async function load(locale: string): Promise<WhatsNewEntry[]> {
-  const url = `${torqvoiceComUrl()}/api/app/whats-new?locale=${encodeURIComponent(locale)}`
+  const url = whatsNewFeedUrl(locale)
   const response = await fetch(url, {
     signal: AbortSignal.timeout(TIMEOUT_MS),
     headers: { accept: 'application/json' },
@@ -44,7 +59,8 @@ function refresh(locale: string, previous: WhatsNewEntry[]): Promise<void> {
  * The highlights torqvoice.com has published, in the reader's language.
  *
  * Asked for by this server, never by the browser, and with nothing about the
- * workshop in the request: only the language. Kept in memory for ten minutes
+ * workshop in the request: only the language (and, on Torqvoice Cloud, the
+ * deployment's own address). Kept in memory for ten minutes
  * per language; once that has passed the old list is still returned at once
  * and the new one is fetched behind it, so only the very first request after
  * a start ever waits. A failure is an empty list, and the button that opens