Merge branch 'v17/dev'
This commit is contained in:
@@ -558,6 +558,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
+10
-1
@@ -18,5 +18,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
{
|
||||
_distributedCache.RemoveLanguageCache(entities);
|
||||
|
||||
// User groups cache their allowed language ids, so a deleted language must be evicted from
|
||||
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
|
||||
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
|
||||
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
|
||||
// language without a query, and language deletion is rare enough that a full refresh is fine.
|
||||
_distributedCache.RefreshAllUserGroupCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
|
||||
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
|
||||
builder.AddCacheSeeding();
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
|
||||
/// </summary>
|
||||
internal static class RuntimeStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
|
||||
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
|
||||
/// </summary>
|
||||
/// <param name="state">The runtime state.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
|
||||
=> state.Level <= RuntimeLevel.Install
|
||||
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
|
||||
{
|
||||
private readonly IDomainCacheService _domainCacheService;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_domainCacheService = domainCacheService;
|
||||
_runtimeState = runtimeState;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void Handle(UmbracoApplicationStartingNotification notification)
|
||||
{
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Force eager population of the lazily-loaded domain cache.
|
||||
_domainCacheService.GetAll(includeWildcards: true);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
@@ -35,7 +34,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
|
||||
UmbracoApplicationStartingNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './constants.js';
|
||||
export * from './data-mapper/index.js';
|
||||
export * from './detail/index.js';
|
||||
export * from './item/index.js';
|
||||
export * from './pagination/index.js';
|
||||
export * from './repository-base.js';
|
||||
export * from './repository-details.manager.js';
|
||||
export * from './repository-items.manager.js';
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { fetchAllPages } from './fetch-all-pages.function.js';
|
||||
import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository';
|
||||
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
|
||||
import type { UmbPagedModel } from '../types.js';
|
||||
|
||||
interface TestItem {
|
||||
id: number;
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository';
|
||||
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
|
||||
import type { UmbPagedModel } from '../types.js';
|
||||
|
||||
/**
|
||||
* A function that returns a single page of an offset-paginated collection.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './fetch-all-pages.function.js';
|
||||
@@ -1,2 +1 @@
|
||||
export * from './fetch-all-pages.function.js';
|
||||
export * from './is-offset-request.guard.js';
|
||||
|
||||
+1
-2
@@ -2,10 +2,9 @@ import type { UmbLanguageCollectionFilterModel } from '../types.js';
|
||||
import type { UmbLanguageDetailModel } from '../../types.js';
|
||||
import { UmbLanguageCollectionServerDataSource } from './language-collection.server.data-source.js';
|
||||
import type { UmbLanguageCollectionDataSource } from './types.js';
|
||||
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
|
||||
import { UmbRepositoryBase, fetchAllPages } from '@umbraco-cms/backoffice/repository';
|
||||
import type { UmbCollectionRepository } from '@umbraco-cms/backoffice/collection';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { fetchAllPages } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
// Mirrors the server's default page size for `GET /language` — chosen so the underlying request matches
|
||||
// the unconfigured server contract.
|
||||
|
||||
+2
-4
@@ -22,8 +22,7 @@ export class UmbTiptapToolbarButtonElement<
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.on('selectionUpdate', this.#onEditorUpdate);
|
||||
this.editor.on('update', this.#onEditorUpdate);
|
||||
this.editor.on('transaction', this.#onEditorUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +30,7 @@ export class UmbTiptapToolbarButtonElement<
|
||||
super.disconnectedCallback();
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.off('selectionUpdate', this.#onEditorUpdate);
|
||||
this.editor.off('update', this.#onEditorUpdate);
|
||||
this.editor.off('transaction', this.#onEditorUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { UmbTiptapToolbarButtonElement } from './tiptap-toolbar-button.element.js';
|
||||
import type { UmbTiptapToolbarElementApi } from '../../extensions/types.js';
|
||||
import type { Editor } from '../../externals.js';
|
||||
import { expect } from '@open-wc/testing';
|
||||
|
||||
/**
|
||||
* Minimal editor stub that records `on`/`off` subscriptions and lets tests fire a
|
||||
* `transaction` event directly. Using a stub (rather than a real `Editor`) avoids
|
||||
* Tiptap's DOM-mounting side-effects and keeps the test focused on the listener wiring.
|
||||
*/
|
||||
function makeEditorStub() {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
let markActive = false;
|
||||
|
||||
return {
|
||||
on(event: string, fn: () => void) {
|
||||
const bucket = listeners.get(event) ?? [];
|
||||
bucket.push(fn);
|
||||
listeners.set(event, bucket);
|
||||
},
|
||||
off(event: string, fn: () => void) {
|
||||
const bucket = listeners.get(event) ?? [];
|
||||
listeners.set(event, bucket.filter((f) => f !== fn));
|
||||
},
|
||||
isActive(name: string) {
|
||||
return name === 'bold' && markActive;
|
||||
},
|
||||
/** Simulates a stored-mark transaction: toggles the mark and notifies all `transaction` subscribers. */
|
||||
fireTransaction() {
|
||||
markActive = !markActive;
|
||||
for (const fn of listeners.get('transaction') ?? []) fn();
|
||||
},
|
||||
/** Returns the registered listener count for the given event name. */
|
||||
listenerCount(event: string) {
|
||||
return listeners.get(event)?.length ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('UmbTiptapToolbarButtonElement', () => {
|
||||
let editorStub: ReturnType<typeof makeEditorStub>;
|
||||
let element: UmbTiptapToolbarButtonElement;
|
||||
|
||||
const boldApi: UmbTiptapToolbarElementApi = {
|
||||
isActive: (e?: Editor) => (e as any)?.isActive('bold') === true,
|
||||
isDisabled: () => false,
|
||||
execute: () => {},
|
||||
} as unknown as UmbTiptapToolbarElementApi;
|
||||
|
||||
const boldManifest = {
|
||||
type: 'tiptapToolbarExtension',
|
||||
kind: 'button',
|
||||
alias: 'Umb.Tiptap.Toolbar.Bold',
|
||||
name: 'Bold',
|
||||
meta: { alias: 'bold', label: 'Bold', icon: 'icon-bold' },
|
||||
} as any;
|
||||
|
||||
const look = () => element.shadowRoot?.querySelector('uui-button')?.getAttribute('look');
|
||||
|
||||
beforeEach(() => {
|
||||
editorStub = makeEditorStub();
|
||||
|
||||
element = document.createElement('umb-tiptap-toolbar-button') as UmbTiptapToolbarButtonElement;
|
||||
// editor must be assigned before connectedCallback so the listener is wired on connect.
|
||||
element.editor = editorStub as unknown as Editor;
|
||||
element.api = boldApi;
|
||||
element.manifest = boldManifest;
|
||||
document.body.appendChild(element);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
it('registers a `transaction` listener on connect (not selectionUpdate/update) (#22907)', () => {
|
||||
expect(editorStub.listenerCount('transaction')).to.equal(1);
|
||||
expect(editorStub.listenerCount('selectionUpdate')).to.equal(0);
|
||||
expect(editorStub.listenerCount('update')).to.equal(0);
|
||||
});
|
||||
|
||||
it('activates immediately when a transaction fires with a collapsed-cursor mark toggle (#22907)', async () => {
|
||||
await element.updateComplete;
|
||||
expect(look()).to.equal('default');
|
||||
|
||||
editorStub.fireTransaction(); // simulates stored-mark toggle — no docChanged, no selection change
|
||||
await element.updateComplete;
|
||||
|
||||
expect(look()).to.equal('outline');
|
||||
});
|
||||
|
||||
it('deactivates when the mark is toggled off again', async () => {
|
||||
editorStub.fireTransaction();
|
||||
await element.updateComplete;
|
||||
expect(look()).to.equal('outline');
|
||||
|
||||
editorStub.fireTransaction();
|
||||
await element.updateComplete;
|
||||
|
||||
expect(look()).to.equal('default');
|
||||
});
|
||||
|
||||
it('removes the `transaction` listener on disconnect', () => {
|
||||
expect(editorStub.listenerCount('transaction')).to.equal(1);
|
||||
|
||||
element.remove();
|
||||
|
||||
expect(editorStub.listenerCount('transaction')).to.equal(0);
|
||||
});
|
||||
});
|
||||
+2
-4
@@ -35,8 +35,7 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.on('selectionUpdate', this.#onEditorUpdate);
|
||||
this.editor.on('update', this.#onEditorUpdate);
|
||||
this.editor.on('transaction', this.#onEditorUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +43,7 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement {
|
||||
super.disconnectedCallback();
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.off('selectionUpdate', this.#onEditorUpdate);
|
||||
this.editor.off('update', this.#onEditorUpdate);
|
||||
this.editor.off('transaction', this.#onEditorUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-14
@@ -19,10 +19,9 @@ export const Figure = Node.create<UmbTiptapFigureOptions>({
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
// `null` default avoids emitting an empty `figcaption=""` on freshly built figures.
|
||||
return {
|
||||
figcaption: {
|
||||
default: '',
|
||||
},
|
||||
figcaption: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
@@ -33,17 +32,7 @@ export const Figure = Node.create<UmbTiptapFigureOptions>({
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: this.name,
|
||||
getAttrs: (dom) => {
|
||||
const figcaption = dom.querySelector('figcaption');
|
||||
return {
|
||||
figcaption: figcaption?.textContent || '',
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
return [{ tag: this.name }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
|
||||
+21
-37
@@ -1,6 +1,8 @@
|
||||
import type { Editor } from '../../externals.js';
|
||||
import { NodeSelection } from '../../externals.js';
|
||||
import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js';
|
||||
import { extractFigureAttrs, extractFigureImageData, extractImageMarks } from './media-picker.tiptap-toolbar.utils.js';
|
||||
import type { UmbTiptapMarkInput } from './media-picker.tiptap-toolbar.utils.js';
|
||||
import { getGuidFromUdi, splitStringToArray } from '@umbraco-cms/backoffice/utils';
|
||||
import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
|
||||
@@ -48,19 +50,23 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
|
||||
let currentTarget = editor.getAttributes('image');
|
||||
let currentMediaUdi = this.#extractMediaUdi(currentTarget);
|
||||
let currentCaption: string | undefined;
|
||||
let currentMarks: Array<UmbTiptapMarkInput> = [];
|
||||
const currentFigureAttrs = extractFigureAttrs(editor);
|
||||
|
||||
// If no image found directly, check if cursor is inside a figure (e.g. in figcaption)
|
||||
if (!currentMediaUdi) {
|
||||
const figureData = this.#extractFigureImageData(editor);
|
||||
const figureData = extractFigureImageData(editor);
|
||||
if (figureData) {
|
||||
currentTarget = figureData.imageAttrs;
|
||||
currentMediaUdi = this.#extractMediaUdi(currentTarget);
|
||||
currentCaption = figureData.caption;
|
||||
currentMarks = figureData.marks;
|
||||
// Select the figure so insertContent replaces it instead of inserting at cursor
|
||||
editor.commands.setNodeSelection(figureData.pos);
|
||||
}
|
||||
} else {
|
||||
currentCaption = this.#extractCaption(editor.state.selection);
|
||||
currentMarks = extractImageMarks(editor.state.selection);
|
||||
}
|
||||
|
||||
// If editing existing image, use its UDI; otherwise open media picker
|
||||
@@ -76,7 +82,7 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
|
||||
);
|
||||
if (!media) return;
|
||||
|
||||
this.#insertInEditor(editor, mediaGuid, media);
|
||||
this.#insertInEditor(editor, mediaGuid, media, currentMarks, currentFigureAttrs);
|
||||
}
|
||||
|
||||
#extractMediaUdi(imageAttributes: Record<string, unknown>): string | undefined {
|
||||
@@ -98,38 +104,6 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
|
||||
return caption;
|
||||
}
|
||||
|
||||
#extractFigureImageData(
|
||||
editor: Editor,
|
||||
): { imageAttrs: Record<string, unknown>; caption?: string; pos: number } | undefined {
|
||||
const { $from } = editor.state.selection;
|
||||
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === 'figure') {
|
||||
let imageAttrs: Record<string, unknown> = {};
|
||||
let caption: string | undefined;
|
||||
|
||||
node.descendants((child) => {
|
||||
if (child.type.name === 'image') {
|
||||
imageAttrs = { ...child.attrs };
|
||||
return false;
|
||||
}
|
||||
if (child.type.name === 'figcaption') {
|
||||
caption = child.textContent || undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageAttrs['data-udi']) {
|
||||
return { imageAttrs, caption, pos: $from.before(depth) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async #openMediaPicker(currentMediaUdi?: string): Promise<string | undefined> {
|
||||
const allowedIds = this.#allowedMediaTypeIds;
|
||||
const modalHandler = this.#modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, {
|
||||
@@ -162,7 +136,13 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
|
||||
return modalHandler?.onSubmit().catch(() => undefined);
|
||||
}
|
||||
|
||||
async #insertInEditor(editor: Editor, mediaUnique: string, media: UmbMediaCaptionAltTextModalValue) {
|
||||
async #insertInEditor(
|
||||
editor: Editor,
|
||||
mediaUnique: string,
|
||||
media: UmbMediaCaptionAltTextModalValue,
|
||||
currentMarks: Array<UmbTiptapMarkInput> = [],
|
||||
currentFigureAttrs?: Record<string, unknown>,
|
||||
) {
|
||||
if (!media?.url) return;
|
||||
|
||||
const width = media.width || this.maxImageSize;
|
||||
@@ -187,16 +167,20 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
|
||||
height: height.toString(),
|
||||
};
|
||||
|
||||
// Forward inline marks (e.g. umbLink for a wrapping `<a>`) onto the replacement node.
|
||||
const marks = currentMarks.length ? currentMarks : undefined;
|
||||
|
||||
if (media.caption) {
|
||||
return editor.commands.insertContent({
|
||||
type: 'figure',
|
||||
attrs: currentFigureAttrs,
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'image', attrs: img }] },
|
||||
{ type: 'paragraph', content: [{ type: 'image', attrs: img, marks }] },
|
||||
{ type: 'figcaption', content: [{ type: 'text', text: media.caption }] },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.setImage(img);
|
||||
return editor.commands.insertContent({ type: 'image', attrs: img, marks });
|
||||
}
|
||||
}
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { extractFigureAttrs, extractFigureImageData, extractImageMarks } from './media-picker.tiptap-toolbar.utils.js';
|
||||
import { Document, Editor, Paragraph, Text } from '../../externals.js';
|
||||
import { UmbImage } from '../image/image.tiptap-extension.js';
|
||||
import { UmbLink } from '../link/link.tiptap-extension.js';
|
||||
import { Figure } from '../figure/figure.tiptap-extension.js';
|
||||
import { Figcaption } from '../figure/figcaption.tiptap-extension.js';
|
||||
import { expect } from '@open-wc/testing';
|
||||
|
||||
describe('media-picker.tiptap-toolbar.utils', () => {
|
||||
let editor: Editor;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
editor = new Editor({
|
||||
element: host,
|
||||
extensions: [Document, Paragraph, Text, UmbImage.configure({ inline: true }), UmbLink, Figure, Figcaption],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function findNodePos(typeName: string): number {
|
||||
let foundPos = -1;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (foundPos !== -1) return false;
|
||||
if (node.type.name === typeName) {
|
||||
foundPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return foundPos;
|
||||
}
|
||||
|
||||
describe('extractImageMarks', () => {
|
||||
it('returns the umbLink mark when an image with a link is selected', () => {
|
||||
editor.commands.setContent(
|
||||
'<p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('image'));
|
||||
|
||||
const marks = extractImageMarks(editor.state.selection);
|
||||
|
||||
expect(marks).to.have.lengthOf(1);
|
||||
expect(marks[0].type).to.equal('umbLink');
|
||||
expect(marks[0].attrs?.href).to.equal('https://example.com');
|
||||
});
|
||||
|
||||
it('returns an empty array for an image with no marks', () => {
|
||||
editor.commands.setContent('<p><img src="foo.png" data-udi="umb://media/abc"></p>');
|
||||
editor.commands.setNodeSelection(findNodePos('image'));
|
||||
|
||||
expect(extractImageMarks(editor.state.selection)).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the selection is not a NodeSelection on an image', () => {
|
||||
editor.commands.setContent('<p>plain text</p>');
|
||||
editor.commands.selectAll();
|
||||
|
||||
expect(extractImageMarks(editor.state.selection)).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('drills into a NodeSelection on a figure to recover the inner image marks', () => {
|
||||
// An atomic figure absorbs clicks meant for the inner image: the resulting
|
||||
// selection lands on the figure node, not on the image inside it.
|
||||
editor.commands.setContent(
|
||||
[
|
||||
'<figure>',
|
||||
' <p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
' <figcaption>Caption text</figcaption>',
|
||||
'</figure>',
|
||||
].join(''),
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('figure'));
|
||||
|
||||
const marks = extractImageMarks(editor.state.selection);
|
||||
|
||||
expect(marks).to.have.lengthOf(1);
|
||||
expect(marks[0].type).to.equal('umbLink');
|
||||
expect(marks[0].attrs?.href).to.equal('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFigureAttrs', () => {
|
||||
it('returns the figure attrs when the selection is a NodeSelection on the figure', () => {
|
||||
editor.commands.setContent(
|
||||
'<figure figcaption="Mirror text">' +
|
||||
'<p><img src="foo.png" data-udi="umb://media/abc"></p>' +
|
||||
'<figcaption>Caption text</figcaption>' +
|
||||
'</figure>',
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('figure'));
|
||||
|
||||
expect(extractFigureAttrs(editor)?.figcaption).to.equal('Mirror text');
|
||||
});
|
||||
|
||||
it('returns the figure attrs when the cursor is inside the figure', () => {
|
||||
editor.commands.setContent(
|
||||
'<figure figcaption="Mirror text">' +
|
||||
'<p><img src="foo.png" data-udi="umb://media/abc"></p>' +
|
||||
'<figcaption>Caption text</figcaption>' +
|
||||
'</figure>',
|
||||
);
|
||||
editor.commands.setTextSelection(findNodePos('figcaption') + 1);
|
||||
|
||||
expect(extractFigureAttrs(editor)?.figcaption).to.equal('Mirror text');
|
||||
});
|
||||
|
||||
it('returns undefined when no figure wraps the selection', () => {
|
||||
editor.commands.setContent('<p>plain text</p>');
|
||||
editor.commands.selectAll();
|
||||
|
||||
expect(extractFigureAttrs(editor)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFigureImageData', () => {
|
||||
it('extracts image attrs, caption text, and the umbLink mark from a figure', () => {
|
||||
editor.commands.setContent(
|
||||
[
|
||||
'<figure>',
|
||||
' <p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
' <figcaption>Caption text</figcaption>',
|
||||
'</figure>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
// Place the text cursor inside the figcaption content (the `+1` skips the figcaption's opening token).
|
||||
const figcaptionPos = findNodePos('figcaption');
|
||||
expect(figcaptionPos, 'figcaption position').to.be.greaterThan(-1);
|
||||
editor.commands.setTextSelection(figcaptionPos + 1);
|
||||
|
||||
const data = extractFigureImageData(editor);
|
||||
|
||||
expect(data, 'figure data').to.exist;
|
||||
expect(data!.imageAttrs['data-udi']).to.equal('umb://media/abc');
|
||||
expect(data!.caption).to.equal('Caption text');
|
||||
expect(data!.marks).to.have.lengthOf(1);
|
||||
expect(data!.marks[0].type).to.equal('umbLink');
|
||||
expect(data!.marks[0].attrs?.href).to.equal('https://example.com');
|
||||
});
|
||||
|
||||
it('also resolves when the selection is a NodeSelection on the figure itself', () => {
|
||||
editor.commands.setContent(
|
||||
[
|
||||
'<figure>',
|
||||
' <p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
' <figcaption>Caption text</figcaption>',
|
||||
'</figure>',
|
||||
].join(''),
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('figure'));
|
||||
|
||||
const data = extractFigureImageData(editor);
|
||||
|
||||
expect(data, 'figure data').to.exist;
|
||||
expect(data!.imageAttrs['data-udi']).to.equal('umb://media/abc');
|
||||
expect(data!.caption).to.equal('Caption text');
|
||||
expect(data!.marks[0].type).to.equal('umbLink');
|
||||
});
|
||||
|
||||
it('returns undefined when the selection is not inside a figure', () => {
|
||||
editor.commands.setContent('<p>plain text</p>');
|
||||
editor.commands.selectAll();
|
||||
|
||||
expect(extractFigureImageData(editor)).to.be.undefined;
|
||||
});
|
||||
|
||||
it('returns undefined when a figure contains no image (only a caption)', () => {
|
||||
editor.commands.setContent('<figure><p>no image</p><figcaption>cap</figcaption></figure>');
|
||||
|
||||
const figcaptionPos = findNodePos('figcaption');
|
||||
editor.commands.setTextSelection(figcaptionPos + 1);
|
||||
|
||||
expect(extractFigureImageData(editor)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip via insertContent', () => {
|
||||
it('re-inserting an image with the extracted marks keeps the surrounding link', () => {
|
||||
editor.commands.setContent(
|
||||
'<p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('image'));
|
||||
|
||||
const marks = extractImageMarks(editor.state.selection);
|
||||
|
||||
editor.commands.insertContent({
|
||||
type: 'image',
|
||||
attrs: { src: 'bar.png', 'data-udi': 'umb://media/abc' },
|
||||
marks,
|
||||
});
|
||||
|
||||
expect(editor.getHTML()).to.match(/<a[^>]*href="https:\/\/example\.com"[^>]*>\s*<img[^>]+src="bar\.png"/);
|
||||
});
|
||||
|
||||
it('re-inserting a figure with the extracted marks on the inner image keeps the surrounding link', () => {
|
||||
editor.commands.setContent(
|
||||
[
|
||||
'<figure>',
|
||||
' <p><a href="https://example.com"><img src="foo.png" data-udi="umb://media/abc"></a></p>',
|
||||
' <figcaption>Original caption</figcaption>',
|
||||
'</figure>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
const figcaptionPos = findNodePos('figcaption');
|
||||
editor.commands.setTextSelection(figcaptionPos + 1);
|
||||
|
||||
const figureData = extractFigureImageData(editor);
|
||||
expect(figureData, 'figure data').to.exist;
|
||||
editor.commands.setNodeSelection(figureData!.pos);
|
||||
|
||||
editor.commands.insertContent({
|
||||
type: 'figure',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: 'bar.png', 'data-udi': 'umb://media/abc' },
|
||||
marks: figureData!.marks,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'figcaption', content: [{ type: 'text', text: 'New caption' }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(editor.getHTML(), 'figure HTML').to.match(
|
||||
/<a[^>]*href="https:\/\/example\.com"[^>]*>\s*<img[^>]+src="bar\.png"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('re-applying the captured figure attrs through insertContent preserves them', () => {
|
||||
editor.commands.setContent(
|
||||
'<figure figcaption="Mirror text">' +
|
||||
'<p><img src="foo.png" data-udi="umb://media/abc"></p>' +
|
||||
'<figcaption>Caption text</figcaption>' +
|
||||
'</figure>',
|
||||
);
|
||||
editor.commands.setNodeSelection(findNodePos('figure'));
|
||||
const attrs = extractFigureAttrs(editor);
|
||||
|
||||
editor.commands.insertContent({
|
||||
type: 'figure',
|
||||
attrs,
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'image', attrs: { src: 'bar.png', 'data-udi': 'umb://media/abc' } }] },
|
||||
{ type: 'figcaption', content: [{ type: 'text', text: 'New caption' }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(editor.getHTML()).to.include('figcaption="Mirror text"');
|
||||
});
|
||||
});
|
||||
});
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import type { Editor, ProseMirrorNode } from '../../externals.js';
|
||||
import { NodeSelection } from '../../externals.js';
|
||||
|
||||
/**
|
||||
* The plain-object shape of a Tiptap/ProseMirror mark, suitable for passing to
|
||||
* `insertContent` JSON. Distinct from the runtime `Mark` instance, which carries
|
||||
* a `MarkType` reference and can't be inserted directly.
|
||||
*/
|
||||
export type UmbTiptapMarkInput = { type: string; attrs?: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Data extracted from a `figure` enclosing the current selection: the inner
|
||||
* image's attributes and marks, the figcaption text (if any), and the figure's
|
||||
* document position so callers can replace it via `setNodeSelection` / `insertContent`.
|
||||
*/
|
||||
export type UmbFigureImageData = {
|
||||
imageAttrs: Record<string, unknown>;
|
||||
caption?: string;
|
||||
pos: number;
|
||||
marks: Array<UmbTiptapMarkInput>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the marks (e.g. `umbLink`) off the selected image node. When the
|
||||
* selection is on a container that wraps an image (e.g. a `figure`, which is
|
||||
* atomic and can't be selected through), drills in to find the inner image's
|
||||
* marks. Returns an empty array when no image is reachable.
|
||||
* @param {unknown} selection The current editor selection.
|
||||
* @returns {Array<UmbTiptapMarkInput>} The marks on the image, or an empty array.
|
||||
*/
|
||||
export function extractImageMarks(selection: unknown): Array<UmbTiptapMarkInput> {
|
||||
if (!(selection instanceof NodeSelection)) return [];
|
||||
|
||||
if (selection.node.type.name === 'image') {
|
||||
return selection.node.marks.map((mark) => ({ type: mark.type.name, attrs: { ...mark.attrs } }));
|
||||
}
|
||||
|
||||
let marks: Array<UmbTiptapMarkInput> = [];
|
||||
selection.node.descendants((child) => {
|
||||
if (child.type.name === 'image') {
|
||||
marks = child.marks.map((mark) => ({ type: mark.type.name, attrs: { ...mark.attrs } }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the `figure` that surrounds the current selection. Handles both a
|
||||
* `NodeSelection` directly on the figure (the common case for atomic figures)
|
||||
* and a selection inside the figure's descendants.
|
||||
* @param {Editor} editor The Tiptap editor instance.
|
||||
* @returns {{ node: ProseMirrorNode; pos: number } | undefined} The figure node and its document position, or `undefined` if no figure surrounds the selection.
|
||||
*/
|
||||
function findEnclosingFigure(editor: Editor): { node: ProseMirrorNode; pos: number } | undefined {
|
||||
const { selection } = editor.state;
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'figure') {
|
||||
return { node: selection.node, pos: selection.from };
|
||||
}
|
||||
const { $from } = selection;
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === 'figure') {
|
||||
return { node, pos: $from.before(depth) };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the wrapping `figure` node's attributes from the current selection, so
|
||||
* they can be re-applied when the figure is rebuilt.
|
||||
* @param {Editor} editor The Tiptap editor instance.
|
||||
* @returns {Record<string, unknown> | undefined} The figure's attrs, or `undefined` if no figure surrounds the selection.
|
||||
*/
|
||||
export function extractFigureAttrs(editor: Editor): Record<string, unknown> | undefined {
|
||||
const figure = findEnclosingFigure(editor);
|
||||
return figure ? { ...figure.node.attrs } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the `figure` that surrounds the current selection and pulls out the
|
||||
* inner image's attributes and marks, the figcaption text, and the figure's
|
||||
* document position so callers can replace it.
|
||||
* @param {Editor} editor The Tiptap editor instance.
|
||||
* @returns {UmbFigureImageData | undefined} The figure data, or `undefined` if no figure surrounds the selection or it contains no image with a `data-udi`.
|
||||
*/
|
||||
export function extractFigureImageData(editor: Editor): UmbFigureImageData | undefined {
|
||||
const figure = findEnclosingFigure(editor);
|
||||
if (!figure) return undefined;
|
||||
|
||||
let imageAttrs: Record<string, unknown> = {};
|
||||
let caption: string | undefined;
|
||||
let marks: Array<UmbTiptapMarkInput> = [];
|
||||
|
||||
figure.node.descendants((child) => {
|
||||
if (child.type.name === 'image') {
|
||||
imageAttrs = { ...child.attrs };
|
||||
marks = child.marks.map((mark) => ({ type: mark.type.name, attrs: { ...mark.attrs } }));
|
||||
return false;
|
||||
}
|
||||
if (child.type.name === 'figcaption') {
|
||||
caption = child.textContent || undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!imageAttrs['data-udi']) return undefined;
|
||||
|
||||
return { imageAttrs, caption, pos: figure.pos, marks };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Umbraco.Tests.Integration
|
||||
|
||||
Integration tests that boot a real Umbraco container (`UmbracoBuilder`) against a real database
|
||||
(SQLite in-memory by default, LocalDb/SQL Server optionally — see the root `CLAUDE.md` →
|
||||
"Integration Test Database Configuration"). Most fixtures derive from `UmbracoIntegrationTest`.
|
||||
|
||||
## Testing caching and cache refreshers
|
||||
|
||||
`UmbracoIntegrationTest` is wired for isolation and speed, **not** cache fidelity. Three harness
|
||||
defaults will silently make a cache-related test pass **regardless of the code under test** (a false
|
||||
green — it passes with and without the fix). When the behaviour under test involves repository
|
||||
caching, cache invalidation, or a `*DistributedCacheNotificationHandler`, override them in
|
||||
`CustomTestSetup(IUmbracoBuilder builder)`:
|
||||
|
||||
1. **`AppCaches.NoCache` is registered**, so repositories never actually cache and a stale-cache
|
||||
scenario cannot be reproduced. Register a real cache (pattern: `Umbraco.Core/Cache/RuntimeCacheTests`):
|
||||
```csharp
|
||||
builder.Services.AddUnique(_ => new AppCaches(
|
||||
new DeepCloneAppCache(new ObjectCacheAppCache()),
|
||||
NoAppCache.Instance,
|
||||
new IsolatedCaches(_ => new DeepCloneAppCache(new ObjectCacheAppCache()))));
|
||||
```
|
||||
|
||||
2. **A no-op server messenger is registered** (`NoopServerMessenger`, in
|
||||
`DependencyInjection/UmbracoBuilderExtensions.cs`), so `DistributedCache.Refresh*/RefreshAll`
|
||||
never actually runs the cache refreshers. Register a synchronous local messenger so refreshers
|
||||
execute in-process:
|
||||
```csharp
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
```
|
||||
`LocalServerMessenger` (in `Umbraco.Infrastructure/Services/ContentEventsTests.cs`) uses
|
||||
`distributedEnabled: false`, so `ServerMessengerBase` delivers locally and invokes the refresher
|
||||
synchronously.
|
||||
|
||||
3. **The `*DistributedCacheNotificationHandler` set is NOT auto-registered** by the harness — these
|
||||
are wired in `UmbracoBuilder.CoreServices` for the real app only. To test that a notification
|
||||
invalidates a cache, register the handler under test explicitly (pattern:
|
||||
`Umbraco.Core/Services/ContentTypeEditingServiceTests`):
|
||||
```csharp
|
||||
builder.AddNotificationHandler<LanguageDeletedNotification, LanguageDeletedDistributedCacheNotificationHandler>();
|
||||
```
|
||||
|
||||
Always confirm the test fails before the fix and passes after (root `CLAUDE.md` → "Tests for a bug
|
||||
fix must fail before the fix"). With the defaults above left in place, a cache test cannot fail, so
|
||||
it proves nothing.
|
||||
@@ -0,0 +1,93 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Builders.Extensions;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the correctness of the user group service's caching of allowed languages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives in its own fixture because the cache/messenger overrides in CustomTestSetup change the
|
||||
/// environment for every test in a fixture, and the rest of UserGroupServiceTests should keep running
|
||||
/// against the default (no-cache) harness.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
|
||||
internal sealed class UserGroupLanguageCacheTests : UmbracoIntegrationTest
|
||||
{
|
||||
private IUserGroupService UserGroupService => GetRequiredService<IUserGroupService>();
|
||||
|
||||
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();
|
||||
|
||||
// The integration harness registers AppCaches.NoCache (so user groups are never actually cached)
|
||||
// and a no-op server messenger (so cache refreshers never run). Use a real cache and a local,
|
||||
// synchronous messenger - as the web pipeline effectively does - so the stale-cache scenario and
|
||||
// its invalidation can be exercised.
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
builder.Services.AddUnique(_ => new AppCaches(
|
||||
new DeepCloneAppCache(new ObjectCacheAppCache()),
|
||||
NoAppCache.Instance,
|
||||
new IsolatedCaches(_ => new DeepCloneAppCache(new ObjectCacheAppCache()))));
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
|
||||
// The harness does not wire up the *DistributedCacheNotificationHandler set (those are only
|
||||
// registered for the real app in UmbracoBuilder.CoreServices), so register the handler under
|
||||
// test explicitly.
|
||||
builder.AddNotificationHandler<LanguageDeletedNotification, LanguageDeletedDistributedCacheNotificationHandler>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Can_Evict_Deleted_Language_From_Cached_User_Group()
|
||||
{
|
||||
var language = new LanguageBuilder().WithCultureInfo("nb-NO").Build();
|
||||
var languageCreated = await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(languageCreated.Success);
|
||||
|
||||
var userGroup = new UserGroup(ShortStringHelper)
|
||||
{
|
||||
Name = "Language Group",
|
||||
Alias = "languageGroup",
|
||||
HasAccessToAllLanguages = false,
|
||||
};
|
||||
userGroup.AddAllowedLanguage(language.Id);
|
||||
|
||||
var created = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(created.Success);
|
||||
var userGroupId = created.Result!.Id;
|
||||
|
||||
// Populate both the per-id cache and the "get all" cache (the list/filter endpoint that
|
||||
// actually surfaced the bug uses the latter) while the language still exists.
|
||||
var cachedById = await UserGroupService.GetAsync(userGroupId);
|
||||
Assert.IsNotNull(cachedById);
|
||||
Assert.IsTrue(cachedById!.AllowedLanguages.Contains(language.Id));
|
||||
|
||||
var cachedFromAll = (await UserGroupService.GetAllAsync(0, int.MaxValue)).Items.First(x => x.Id == userGroupId);
|
||||
Assert.IsTrue(cachedFromAll.AllowedLanguages.Contains(language.Id));
|
||||
|
||||
var deleted = await LanguageService.DeleteAsync(language.IsoCode, Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(deleted.Success);
|
||||
|
||||
// Both cached read paths must drop the now-deleted language.
|
||||
var reloadedById = await UserGroupService.GetAsync(userGroupId);
|
||||
Assert.IsNotNull(reloadedById);
|
||||
Assert.IsFalse(
|
||||
reloadedById!.AllowedLanguages.Contains(language.Id),
|
||||
"A deleted language should not remain on the cached user group (get-by-id path).");
|
||||
|
||||
var reloadedFromAll = (await UserGroupService.GetAllAsync(0, int.MaxValue)).Items.First(x => x.Id == userGroupId);
|
||||
Assert.IsFalse(
|
||||
reloadedFromAll.AllowedLanguages.Contains(language.Id),
|
||||
"A deleted language should not remain on the cached user group (get-all path).");
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class RuntimeStateExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// At or below <see cref="RuntimeLevel.Install"/> the front-end cannot serve content, so seeding is
|
||||
/// always skipped regardless of the maintenance-page setting.
|
||||
/// </summary>
|
||||
[TestCase(RuntimeLevel.BootFailed, true)]
|
||||
[TestCase(RuntimeLevel.BootFailed, false)]
|
||||
[TestCase(RuntimeLevel.Unknown, true)]
|
||||
[TestCase(RuntimeLevel.Unknown, false)]
|
||||
[TestCase(RuntimeLevel.Boot, true)]
|
||||
[TestCase(RuntimeLevel.Boot, false)]
|
||||
[TestCase(RuntimeLevel.Install, true)]
|
||||
[TestCase(RuntimeLevel.Install, false)]
|
||||
public void ShouldSkipStartupSeeding_WhenLevelIsInstallOrBelow_ReturnsTrue(
|
||||
RuntimeLevel level, bool showMaintenancePage)
|
||||
{
|
||||
var state = MockState(level);
|
||||
var globalSettings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = showMaintenancePage };
|
||||
Assert.That(state.ShouldSkipStartupSeeding(globalSettings), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// During <see cref="RuntimeLevel.Upgrade"/> the result follows the maintenance-page setting: shown means
|
||||
/// the front-end is blocked, so seeding is skipped; hidden means content is served, so seeding proceeds.
|
||||
/// </summary>
|
||||
[TestCase(true, true)]
|
||||
[TestCase(false, false)]
|
||||
public void ShouldSkipStartupSeeding_WhenLevelIsUpgrade_FollowsMaintenancePageSetting(
|
||||
bool showMaintenancePage, bool expected)
|
||||
{
|
||||
var state = MockState(RuntimeLevel.Upgrade);
|
||||
var globalSettings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = showMaintenancePage };
|
||||
Assert.That(state.ShouldSkipStartupSeeding(globalSettings), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RuntimeLevel.Upgrading"/> (background upgrade, server already serving) and
|
||||
/// <see cref="RuntimeLevel.Run"/> are content-serving states, so seeding proceeds even when the
|
||||
/// maintenance page is configured to show during upgrades.
|
||||
/// </summary>
|
||||
[TestCase(RuntimeLevel.Upgrading)]
|
||||
[TestCase(RuntimeLevel.Run)]
|
||||
public void ShouldSkipStartupSeeding_WhenLevelIsUpgradingOrRun_ReturnsFalse(RuntimeLevel level)
|
||||
{
|
||||
var state = MockState(level);
|
||||
var globalSettings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true };
|
||||
Assert.That(state.ShouldSkipStartupSeeding(globalSettings), Is.False);
|
||||
}
|
||||
|
||||
private static IRuntimeState MockState(RuntimeLevel level)
|
||||
=> Mock.Of<IRuntimeState>(s => s.Level == level);
|
||||
}
|
||||
Reference in New Issue
Block a user