-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecipe-view.base.test.ts
More file actions
452 lines (382 loc) · 17.8 KB
/
Copy pathrecipe-view.base.test.ts
File metadata and controls
452 lines (382 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { RecipeViewBase } from './recipe-view.base';
import { AuthService } from '../../services/auth.service';
import { PersistenceService } from '../../services/persistence.service';
import { GeminiService } from '../../services/gemini.service';
import { RecipeStateService } from '../../services/recipe-state.service';
import { ToastService } from '../../services/toast.service';
import { ModalService } from '../../services/modal.service';
// KAN-126 (#3209): the shared seam between GeneratorComponent and
// RecipeDetailComponent. The component test files cover the behaviour through
// each surface; this file pins the *contract of the abstraction itself* — above
// all onPublishDenied, the single hook where the two components legitimately
// diverge (generator opens the auth modal, recipe-detail stays silent).
describe('RecipeViewBase', () => {
let toastShow: ReturnType<typeof vi.fn>;
let denied: Mock<() => void>;
beforeEach(() => {
toastShow = vi.fn();
denied = vi.fn<() => void>();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const createHost = (
opts: {
isGuest?: boolean;
publishStateSync?: string;
generateImage?: (id: string, force: boolean) => Promise<string>;
refreshRecipeFromApi?: (id: string) => Promise<unknown>;
savedRecipes?: unknown[];
} = {}
) => {
const recipeState = runInInjectionContext(
Injector.create({ providers: [] }),
() => new RecipeStateService()
);
// One spy behind both save entry points: saveNotes uses saveRecipe,
// togglePublic uses saveRecipeDetailed (KAN-155). `{ ok: true }` satisfies
// both — it is the SaveOutcome the detailed caller reads, and truthy for the
// boolean one.
const persistenceSaveRecipe = vi.fn().mockResolvedValue({ ok: true });
// KAN-255: the post-image reconcile. Default null = "the row could not be
// read", the branch that must leave the optimistic local write standing.
const refreshRecipeFromApi = vi.fn(opts.refreshRecipeFromApi ?? (async () => null));
const authUser = {
isGuest: opts.isGuest ?? false,
savedRecipes: (opts.savedRecipes ?? []) as unknown[],
};
const injector = Injector.create({
providers: [
{
provide: AuthService,
useValue: {
currentUser: () => authUser,
saveRecipe: vi.fn(),
updateRecipeField: vi.fn(),
},
},
{
provide: PersistenceService,
useValue: {
saveRecipe: persistenceSaveRecipe,
saveRecipeDetailed: persistenceSaveRecipe,
refreshRecipeFromApi,
publishStateSync: () => opts.publishStateSync ?? 'synced',
},
},
{
provide: GeminiService,
useValue: { generateImage: opts.generateImage ?? vi.fn() },
},
{ provide: RecipeStateService, useValue: recipeState },
{ provide: ToastService, useValue: { show: toastShow } },
{ provide: ModalService, useValue: { openAuth: vi.fn() } },
],
});
class Host extends RecipeViewBase {
protected override onPublishDenied(): void {
denied();
}
}
const host = runInInjectionContext(injector, () => new Host());
const authService = injector.get(AuthService);
return { host, persistenceSaveRecipe, refreshRecipeFromApi, recipeState, authService };
};
it('calls the onPublishDenied hook instead of saving when the user cannot publish', async () => {
const { host, persistenceSaveRecipe } = createHost({ isGuest: true });
await host.togglePublic({ id: 'r1', name: 'Vegan Cornbread' } as never);
expect(denied).toHaveBeenCalledOnce();
expect(persistenceSaveRecipe).not.toHaveBeenCalled();
});
it('does not call the hook when the user can publish', async () => {
const { host, persistenceSaveRecipe } = createHost({ isGuest: false });
await host.togglePublic({ id: 'r1', name: 'Vegan Cornbread' } as never);
expect(denied).not.toHaveBeenCalled();
expect(persistenceSaveRecipe).toHaveBeenCalledOnce();
});
it('flips publish state on a copy, never mutating the passed recipe', async () => {
const { host, persistenceSaveRecipe } = createHost({ isGuest: false });
const recipe = { id: 'r1', name: 'Vegan Cornbread' } as unknown as { is_public?: boolean };
await host.togglePublic(recipe as never);
expect(recipe.is_public).toBeFalsy();
expect(persistenceSaveRecipe).toHaveBeenCalledWith(
expect.objectContaining({ id: 'r1', is_public: true })
);
});
// GH #3255 (KAN-143): the reason a toggle is unavailable used to live only in
// a `title` on a `disabled` button, which neither surfaces a tooltip reliably
// nor is reachable by keyboard — so the explanation could never appear.
// Activating an unavailable toggle now says why out loud.
it('says why a canonical recipe cannot be unpublished', async () => {
const { host, persistenceSaveRecipe } = createHost();
await host.togglePublic({
id: 'r1',
name: 'Vegan Cornbread',
is_canonical: true,
is_public: true,
} as never);
expect(toastShow).toHaveBeenCalledWith(expect.stringMatching(/locked/i));
expect(persistenceSaveRecipe).not.toHaveBeenCalled();
});
it('says why the toggle is inert while the publish state is still syncing', async () => {
const { host, persistenceSaveRecipe } = createHost({ publishStateSync: 'pending' });
await host.togglePublic({ id: 'r1', name: 'Vegan Cornbread' } as never);
expect(toastShow).toHaveBeenCalledWith(expect.stringMatching(/checking publish state/i));
expect(persistenceSaveRecipe).not.toHaveBeenCalled();
});
// RCP-74 poison pill, owed since the "publishes the copy when confirmed"
// test was removed with the KAN-137 confirm flow: no test would otherwise
// fail if the guard were relaxed and a saved copy published again. confirm
// is stubbed to ACCEPT, so resurrecting any confirm-then-publish path
// reaches the save and fails here.
it('refuses to publish a saved copy — guard path, no confirm, nothing saved', async () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
const { host, persistenceSaveRecipe } = createHost({ isGuest: false });
const recipe = {
id: 'copy-1',
name: 'Vegan Cornbread',
sourceSlug: 'vegan-cornbread',
} as unknown as { is_public?: boolean };
host.recipe.set(recipe as never);
await host.togglePublic(recipe as never);
expect(persistenceSaveRecipe).not.toHaveBeenCalled();
expect((host.recipe() as { is_public?: boolean } | null)?.is_public).toBeFalsy();
// The D1 refusal: a redirect to the live page, not a plain scold.
expect(toastShow).toHaveBeenCalledWith(expect.stringMatching(/already live/i), null, 6000, {
url: '/r/vegan-cornbread',
label: 'here',
});
});
// GH #3256 (KAN-144): manual entry wrote the user's own notes into `notes`,
// the generated-content field the editor treats as read-only — so those notes
// were frozen and the pencil opened an empty box. Manual recipes are never
// published, so nothing public depends on their `notes`: adopt the text.
it('seeds the editor from the legacy notes of a manually entered recipe', () => {
const { host } = createHost();
host.recipe.set({
id: 'r1',
name: 'Grandma Cornbread',
origin: 'manual',
notes: 'skillet must be screaming hot',
} as never);
host.startEditNotes();
expect(host.editedNotes()).toBe('skillet must be screaming hot');
});
it('migrates legacy manual notes into personalNotes and clears the legacy field', async () => {
const { host, persistenceSaveRecipe } = createHost();
host.recipe.set({
id: 'r1',
name: 'Grandma Cornbread',
origin: 'manual',
notes: 'skillet must be screaming hot',
} as never);
host.startEditNotes();
host.editedNotes.set('skillet must be screaming hot — and use bacon fat');
await host.saveNotes();
const saved = host.recipe() as { notes?: string; personalNotes?: string } | null;
expect(saved?.personalNotes).toBe('skillet must be screaming hot — and use bacon fat');
expect(saved?.notes).toBe('');
expect(persistenceSaveRecipe).toHaveBeenCalledWith(expect.objectContaining({ notes: '' }));
});
it('never adopts the generated notes of a generated recipe', async () => {
const { host } = createHost();
host.recipe.set({
id: 'r1',
name: 'Vegan Cornbread',
origin: 'generated',
notes: 'generated public notes',
} as never);
host.startEditNotes();
expect(host.editedNotes()).toBe('');
host.editedNotes.set('my private tweaks');
await host.saveNotes();
const saved = host.recipe() as { notes?: string; personalNotes?: string } | null;
expect(saved?.notes).toBe('generated public notes');
expect(saved?.personalNotes).toBe('my private tweaks');
});
it('formats fractional and ranged amounts', () => {
const { host } = createHost();
expect(host.formatAmount(0.33)).toBe('1/3');
expect(host.formatAmount(0.75)).toBe('3/4');
expect(host.formatAmount(3)).toBe('3');
expect(host.formatAmount([1, 2])).toBe('1 - 2');
});
it('reads instruction text from both the string and step-object shapes', () => {
const { host } = createHost();
expect(host.instructionText('Preheat the oven')).toBe('Preheat the oven');
expect(host.instructionText({ description: 'Fold the batter' } as never)).toBe(
'Fold the batter'
);
});
// KAN-243 (review finding on #3433): `withCacheBuster` produces a *display*
// marker. #3420's follow-up 61f8e6e persisted that busted URL into
// `savedRecipes` so nav-away-and-back would not re-serve pre-regen bytes —
// which meant a later full save (saveNotes POSTs the whole recipe) wrote the
// client-only `?_t=<epoch>` marker back as the canonical `ai_image_url`.
//
// The marker now lives in RecipeStateService, keyed by recipe id, and is
// applied only when building a display URL. Persisted state stays canonical
// AND the buster still survives navigation.
describe('image regeneration cache-buster (KAN-243)', () => {
const CANONICAL = '/api/recipes/r1/image.jpg';
it('persists the canonical image URL, not the cache-busted display URL', async () => {
const { host, authService } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
});
host.recipe.set({ id: 'r1', name: 'Vegan Cornbread' } as never);
await host.regenerateImage();
expect(authService.updateRecipeField).toHaveBeenCalledWith('r1', 'ai_image_url', CANONICAL);
const persisted = (authService.updateRecipeField as Mock).mock.calls[0][2] as string;
expect(persisted).not.toContain('_t=');
// the in-memory domain object is canonical too
expect((host.recipe() as { ai_image_url?: string }).ai_image_url).toBe(CANONICAL);
});
it('still shows a cache-busted URL for display after regenerating', async () => {
const { host } = createHost({ generateImage: vi.fn().mockResolvedValue(CANONICAL) });
host.recipe.set({ id: 'r1', name: 'Vegan Cornbread' } as never);
await host.regenerateImage();
expect(host.generatedImageUrl()).toMatch(/_t=\d+/);
});
it('regenerate -> navigate away and back -> save notes never POSTs a _t marker', async () => {
const { host, recipeState, persistenceSaveRecipe, authService } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
});
host.recipe.set({ id: 'r1', name: 'Vegan Cornbread', origin: 'generated' } as never);
await host.regenerateImage();
// Nav away, then back: the recipe rehydrates from persisted state, which
// is what `updateRecipeField` wrote.
const persisted = (authService.updateRecipeField as Mock).mock.calls[0][2] as string;
recipeState.clearRecipe();
recipeState.viewRecipe({
id: 'r1',
name: 'Vegan Cornbread',
origin: 'generated',
ai_image_url: persisted,
} as never);
// the buster survives navigation for display...
expect(recipeState.generatedImageUrl()).toMatch(/_t=\d+/);
host.startEditNotes();
host.editedNotes.set('more paprika');
await host.saveNotes();
// ...but never reaches the API payload.
const payload = persistenceSaveRecipe.mock.calls.at(-1)?.[0] as { ai_image_url?: string };
expect(payload.ai_image_url).toBe(CANONICAL);
expect(payload.ai_image_url).not.toContain('_t=');
});
});
// KAN-255: the image pipeline finishes SERVER-side. The worker writes
// `ai_image_gcs`, `ai_metadata.image_generation`, and flips
// `ai_metadata.image_request.status` / `image_enqueue.status` from `pending`
// to `complete`. The client wrote back only `ai_image_url`, so the copy it
// held — and exported as JSON — still read `pending` with no GCS URI.
describe('server image metadata reconcile (KAN-255)', () => {
const CANONICAL = '/api/recipes/r1/image';
// The row the worker leaves behind, as GET /api/recipes/:id returns it.
const SERVER_ROW = {
id: 'r1',
data: {
id: 'r1',
name: 'Vegan Cornbread',
origin: 'generated',
ai_image_url: CANONICAL,
ai_image_gcs: 'gs://tasteslikegood-recipe-images/r1/claim-abc.png',
ai_metadata: {
image_generation: { success: true, user_display_name: 'Background Worker' },
image_enqueue: { status: 'complete' },
image_request: { id: 'req-1', status: 'complete', force_regenerate: false },
},
},
is_canonical: false,
is_public: false,
slug: null,
source_slug: null,
origin: 'generated',
};
const pendingRecipe = () =>
({
id: 'r1',
name: 'Vegan Cornbread',
origin: 'generated',
ai_metadata: {
image_enqueue: { status: 'pending' },
image_request: { id: 'req-1', status: 'pending', force_regenerate: false },
},
}) as never;
it('re-reads the row and adopts the worker-written fields', async () => {
const { host, refreshRecipeFromApi } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
refreshRecipeFromApi: async () => SERVER_ROW.data,
});
host.recipe.set(pendingRecipe());
await host.regenerateImage();
expect(refreshRecipeFromApi).toHaveBeenCalledWith('r1');
const adopted = host.recipe() as unknown as typeof SERVER_ROW.data;
// The exact fields that read pending/null before the fix.
expect(adopted.ai_image_gcs).toBe(SERVER_ROW.data.ai_image_gcs);
expect(adopted.ai_metadata.image_request.status).toBe('complete');
expect(adopted.ai_metadata.image_enqueue.status).toBe('complete');
expect(adopted.ai_metadata.image_generation).toBeDefined();
});
it('exports JSON that matches the API row after the reconcile', async () => {
const { host } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
refreshRecipeFromApi: async () => SERVER_ROW.data,
});
host.recipe.set(pendingRecipe());
await host.regenerateImage();
// exportRecipe stringifies the viewed recipe verbatim; comparing the
// serialized form is the same comparison the AC's repro makes by hand.
expect(JSON.parse(JSON.stringify(host.recipe()))).toEqual(SERVER_ROW.data);
});
it('leaves the optimistic local write standing when the row cannot be read', async () => {
const { host, authService } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
refreshRecipeFromApi: async () => null,
});
host.recipe.set(pendingRecipe());
await host.regenerateImage();
expect(authService.updateRecipeField).toHaveBeenCalledWith('r1', 'ai_image_url', CANONICAL);
expect((host.recipe() as { ai_image_url?: string }).ai_image_url).toBe(CANONICAL);
});
it('does not overwrite the viewed recipe when the user has navigated to another one', async () => {
const { host } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
refreshRecipeFromApi: async () => SERVER_ROW.data,
});
host.recipe.set(pendingRecipe());
const inFlight = host.regenerateImage();
// Nav to a different recipe while the generation is still detached.
host.recipe.set({ id: 'r2', name: 'Chili' } as never);
await inFlight;
expect(host.recipe()?.id).toBe('r2');
});
it('reconciles even after the recipe is no longer the one being viewed', async () => {
// The nav-away repro: the component is gone, the promise is not. The
// local write and the API re-read must BOTH still happen — that is what
// makes the cookbook row correct on return.
const { host, refreshRecipeFromApi, authService } = createHost({
generateImage: vi.fn().mockResolvedValue(CANONICAL),
refreshRecipeFromApi: async () => SERVER_ROW.data,
});
host.recipe.set(pendingRecipe());
const inFlight = host.regenerateImage();
host.recipe.set(null);
await inFlight;
expect(authService.updateRecipeField).toHaveBeenCalledWith('r1', 'ai_image_url', CANONICAL);
expect(refreshRecipeFromApi).toHaveBeenCalledWith('r1');
});
it('does not reconcile when generation fails', async () => {
const { host, refreshRecipeFromApi } = createHost({
generateImage: vi.fn().mockRejectedValue(new Error('timed out')),
});
host.recipe.set(pendingRecipe());
await host.regenerateImage();
expect(refreshRecipeFromApi).not.toHaveBeenCalled();
expect(toastShow).toHaveBeenCalledWith("Couldn't regenerate the image. Please try again.");
});
});
});