feat(bootstrap): fall back to built-in placeholder when marketplace unpublished
Adds published: boolean to the bootstrap wire contract. ConfigService swaps to a new DEFAULT_BOOTSTRAP constant (all feature flags on, generic branding/theme/pages) whenever the backend reports published: false, so an unpublished marketplace renders a working demo instead of a blank or broken page. Missing published field stays backward compatible (treated as true). Documents the brand bootstrap wire shape for backend/ops use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
596
docs/superpowers/plans/2026-08-22-frontend-default-bootstrap.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# Frontend Default Bootstrap Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status.
|
||||
|
||||
**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes.
|
||||
|
||||
**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011).
|
||||
- Fallback is a whole-object swap — no field-level merging with the real response.
|
||||
- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched).
|
||||
- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`.
|
||||
- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `published` to the `BootstrapConfig` contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/shared/models/config/bootstrap-config.model.ts`
|
||||
- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live")
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Add the field to the interface**
|
||||
|
||||
In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`:
|
||||
|
||||
```ts
|
||||
export interface BootstrapConfig {
|
||||
schemaVersion: string;
|
||||
generatedAt: string;
|
||||
published: boolean;
|
||||
tenant: TenantConfig;
|
||||
branding: BrandingConfig;
|
||||
theme: ThemeConfig;
|
||||
company: CompanyConfig;
|
||||
featureFlags: FeatureFlagsConfig;
|
||||
features?: MarketplaceFeaturesConfig;
|
||||
apiEndpoints: ApiEndpointsConfig;
|
||||
localization: LocalizationConfig;
|
||||
seo: SeoConfig;
|
||||
permissions: PermissionsConfig;
|
||||
header?: HeaderConfig;
|
||||
catalog?: CatalogConfig;
|
||||
layout?: PlatformLayoutConfig;
|
||||
navigation: NavigationConfig;
|
||||
footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig;
|
||||
userExperience?: UserExperienceConfig;
|
||||
pages: PageConfig[];
|
||||
staticPages?: StaticPagesConfig;
|
||||
widgetRegistry?: WidgetRegistryConfig;
|
||||
modules?: PlatformModulesConfig;
|
||||
seller?: SellerConfig;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the mock fixture**
|
||||
|
||||
In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3).
|
||||
|
||||
- [ ] **Step 3: Compile check**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
|
||||
git commit -m "feat: add published field to BootstrapConfig contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the `DEFAULT_BOOTSTRAP` constant
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/shared/models/config/default-bootstrap.const.ts`
|
||||
- Modify: `src/app/shared/models/config/index.ts` (export the new file)
|
||||
- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`.
|
||||
- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
|
||||
|
||||
describe('DEFAULT_BOOTSTRAP', () => {
|
||||
it('is marked unpublished', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
|
||||
});
|
||||
|
||||
it('has every feature flag turned on', () => {
|
||||
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
|
||||
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('has at least one page with a hero section', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
|
||||
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
|
||||
expect(heroSection).toBeDefined();
|
||||
});
|
||||
|
||||
it('has a generic brand name, not a real tenant name', () => {
|
||||
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: FAIL — `Cannot find module './default-bootstrap.const'`
|
||||
|
||||
- [ ] **Step 3: Write the constant**
|
||||
|
||||
Create `src/app/shared/models/config/default-bootstrap.const.ts`:
|
||||
|
||||
```ts
|
||||
import { BootstrapConfig } from './bootstrap-config.model';
|
||||
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
|
||||
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
|
||||
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
|
||||
|
||||
/**
|
||||
* Whole-object fallback rendered whenever the backend reports
|
||||
* `published: false` for the resolved marketplace (no published revision
|
||||
* yet). Every feature flag is on so it doubles as a full-surface product
|
||||
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
|
||||
*/
|
||||
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
|
||||
schemaVersion: '1.0.0',
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
published: false,
|
||||
|
||||
tenant: {
|
||||
id: 'tenant-default-unpublished',
|
||||
slug: 'default',
|
||||
code: 'DEFAULT',
|
||||
host: 'default.local',
|
||||
name: 'Marketplace',
|
||||
websiteBaseUrl: 'https://marketplace.local',
|
||||
builderBaseUrl: 'https://builder.marketplace.local',
|
||||
backofficeBaseUrl: 'https://backoffice.marketplace.local',
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
defaultCurrency: 'USD',
|
||||
supportedCurrencies: ['USD'],
|
||||
timezone: 'UTC',
|
||||
},
|
||||
|
||||
branding: {
|
||||
brandName: 'Marketplace',
|
||||
legalName: 'Marketplace',
|
||||
slogan: 'Your store, coming soon',
|
||||
logoUrl: '/icons/icon-192x192.png',
|
||||
logoCompactUrl: '/icons/icon-192x192.png',
|
||||
faviconUrl: '/favicon.ico',
|
||||
appIconUrl: '/icons/icon-192x192.png',
|
||||
supportEmail: 'support@marketplace.local',
|
||||
},
|
||||
|
||||
theme: {
|
||||
themeId: 'default-light',
|
||||
mode: 'light',
|
||||
palette: {
|
||||
primary: '#497671',
|
||||
secondary: '#a1b4b5',
|
||||
accent: '#a7ceca',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
textPrimary: '#1e3c38',
|
||||
textSecondary: '#667a77',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#f5f5f5',
|
||||
border: '#d3dad9',
|
||||
},
|
||||
typography: {
|
||||
primaryFontFamily: 'DM Sans, sans-serif',
|
||||
headingFontFamily: 'DM Sans, sans-serif',
|
||||
baseFontSize: 16,
|
||||
},
|
||||
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
|
||||
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
|
||||
shadows: {
|
||||
sm: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
md: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
lg: '0 12px 32px rgba(73,118,113,0.2)',
|
||||
},
|
||||
iconSet: 'default',
|
||||
},
|
||||
|
||||
company: {
|
||||
companyName: 'Marketplace',
|
||||
address: { country: '', city: '' },
|
||||
contacts: { email: 'support@marketplace.local' },
|
||||
},
|
||||
|
||||
featureFlags: {
|
||||
wishlist: true,
|
||||
compare: true,
|
||||
reviews: true,
|
||||
questions: true,
|
||||
comments: true,
|
||||
recommendations: true,
|
||||
blog: true,
|
||||
chat: true,
|
||||
analytics: true,
|
||||
notifications: true,
|
||||
coupons: true,
|
||||
loyalty: true,
|
||||
giftCards: true,
|
||||
invoices: true,
|
||||
},
|
||||
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
|
||||
|
||||
apiEndpoints: {
|
||||
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
|
||||
website: {},
|
||||
builder: {},
|
||||
backoffice: {},
|
||||
},
|
||||
|
||||
localization: {
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en'],
|
||||
currencyByLocale: { en: 'USD' },
|
||||
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
|
||||
},
|
||||
|
||||
seo: {
|
||||
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
byPageKey: {
|
||||
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
|
||||
},
|
||||
},
|
||||
|
||||
permissions: { definitions: [], roles: [] },
|
||||
|
||||
header: DEFAULT_HEADER_CONFIG,
|
||||
|
||||
navigation: {
|
||||
header: [
|
||||
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
|
||||
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
|
||||
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
|
||||
],
|
||||
footer: [
|
||||
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
|
||||
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
paymentIcons: [],
|
||||
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
|
||||
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
|
||||
},
|
||||
|
||||
staticPages: {
|
||||
'about-us': {
|
||||
route: '/about-us',
|
||||
title: { en: 'About Us' },
|
||||
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
|
||||
},
|
||||
'privacy-policy': {
|
||||
route: '/privacy-policy',
|
||||
title: { en: 'Privacy Policy' },
|
||||
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
'terms-of-service': {
|
||||
route: '/terms-of-service',
|
||||
title: { en: 'Terms of Service' },
|
||||
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
|
||||
},
|
||||
},
|
||||
|
||||
pages: [
|
||||
{
|
||||
id: 'page-home',
|
||||
key: 'home',
|
||||
title: 'Home',
|
||||
route: { path: '/', exact: true },
|
||||
layout: { type: 'default' },
|
||||
seoKey: 'home',
|
||||
visible: true,
|
||||
sections: [
|
||||
{
|
||||
id: 'section-hero',
|
||||
type: 'hero',
|
||||
order: 1,
|
||||
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-hero-main',
|
||||
type: 'hero',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.5rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: {
|
||||
title: { en: 'Welcome to Marketplace' },
|
||||
subtitle: { en: 'This storefront has not been published yet' },
|
||||
ctaLabel: { en: 'Learn more' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'section-categories',
|
||||
type: 'categories',
|
||||
order: 2,
|
||||
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-categories-root',
|
||||
type: 'categories',
|
||||
version: '1.0.0',
|
||||
order: 1,
|
||||
padding: '0.25rem 0',
|
||||
visibility: { desktop: true, tablet: true, mobile: true },
|
||||
visible: true,
|
||||
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Export it from the barrel file**
|
||||
|
||||
In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`):
|
||||
|
||||
```ts
|
||||
export * from './default-bootstrap.const';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
|
||||
Expected: PASS (5 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
|
||||
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/core/config/config.service.ts`
|
||||
- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`.
|
||||
- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `src/app/core/config/config.service.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ConfigService } from './config.service';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
import { ConfigProvider } from './config-provider.interface';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
|
||||
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
|
||||
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
|
||||
}
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let provider: jasmine.SpyObj<ConfigProvider>;
|
||||
|
||||
function setup(response: BootstrapConfig): ConfigService {
|
||||
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
|
||||
provider.loadBootstrap.and.returnValue(of(response));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
|
||||
});
|
||||
return TestBed.inject(ConfigService);
|
||||
}
|
||||
|
||||
it('caches the real response when published is true', done => {
|
||||
const real = makeRealBootstrap();
|
||||
const service = setup(real);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(service.getBootstrapSnapshot()).toEqual(real);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
|
||||
const draft = makeRealBootstrap({ published: false });
|
||||
const service = setup(draft);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result).toEqual(DEFAULT_BOOTSTRAP);
|
||||
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a missing published field as published (backward compatible)', done => {
|
||||
const legacy = makeRealBootstrap();
|
||||
delete (legacy as Partial<BootstrapConfig>).published;
|
||||
const service = setup(legacy);
|
||||
|
||||
service.loadBootstrap().subscribe(result => {
|
||||
expect(result.tenant.name).toBe('Acme');
|
||||
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`.
|
||||
|
||||
- [ ] **Step 3: Implement the swap**
|
||||
|
||||
Replace the body of `src/app/core/config/config.service.ts` with:
|
||||
|
||||
```ts
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConfigService {
|
||||
private readonly provider = inject(CONFIG_PROVIDER);
|
||||
|
||||
private bootstrapSnapshot: BootstrapConfig | null = null;
|
||||
private bootstrap$?: Observable<BootstrapConfig>;
|
||||
private readonly revisionState = signal(0);
|
||||
|
||||
readonly bootstrapRevision = this.revisionState.asReadonly();
|
||||
|
||||
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
|
||||
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
if (!this.bootstrap$ || forceRefresh) {
|
||||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||||
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
|
||||
tap(config => {
|
||||
this.bootstrapSnapshot = config;
|
||||
this.revisionState.update(value => value + 1);
|
||||
}),
|
||||
shareReplay(1),
|
||||
catchError(error => {
|
||||
this.bootstrap$ = undefined;
|
||||
this.bootstrapSnapshot = null;
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.bootstrap$;
|
||||
}
|
||||
|
||||
getBootstrapSnapshot(): BootstrapConfig | null {
|
||||
return this.bootstrapSnapshot;
|
||||
}
|
||||
|
||||
applyBootstrapOverride(next: BootstrapConfig): void {
|
||||
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
|
||||
this.bootstrapSnapshot = cloned;
|
||||
this.bootstrap$ = of(cloned);
|
||||
this.revisionState.update(value => value + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run the full unit suite to check for regressions**
|
||||
|
||||
Run: `ng test --watch=false`
|
||||
Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
|
||||
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: E2E smoke test for the unpublished placeholder
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target.
|
||||
|
||||
- [ ] **Step 1: Read the existing smoke spec to match its conventions**
|
||||
|
||||
Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Add to `e2e/smoke.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
|
||||
await page.route('**/bootstrap', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it to verify it fails**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 1–3 aren't wired in yet if this task runs standalone. If Tasks 1–3 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
|
||||
|
||||
- [ ] **Step 4: Confirm it passes against the real implementation**
|
||||
|
||||
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
|
||||
Expected: PASS, once Tasks 1–3 are committed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/smoke.spec.ts
|
||||
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 2–4 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`).
|
||||
- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task.
|
||||
Reference in New Issue
Block a user