chore(core): Ensure updatedAt is always set for tags in source control feature (#16949)

This commit is contained in:
Andreas Fitzek
2025-07-03 12:21:00 +02:00
committed by GitHub
parent 91fe5109b5
commit b013b6dabe
3 changed files with 120 additions and 6 deletions

View File

@@ -139,6 +139,119 @@ describe('SourceControlService', () => {
}); });
describe('getStatus', () => { describe('getStatus', () => {
it('ensure updatedAt field for last deleted tag', async () => {
// ARRANGE
const user = mock<User>();
user.role = 'global:admin';
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]);
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([]);
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
tagRepository.find.mockResolvedValue([]);
// Define a tag that does only exist remotely.
// Pushing this means it was deleted.
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
tags: [
{
id: 'tag-id',
name: 'some name',
} as TagEntity,
],
mappings: [],
});
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
tags: [],
mappings: [],
});
folderRepository.find.mockResolvedValue([]);
sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
folders: [],
});
sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({
folders: [],
});
// ACT
const pushResult = await sourceControlService.getStatus(user, {
direction: 'push',
verbose: false,
preferLocalVersion: false,
});
// ASSERT
if (!Array.isArray(pushResult)) {
fail('Expected pushResult to be an array.');
}
expect(pushResult).toHaveLength(1);
expect(pushResult.find((i) => i.type === 'tags')?.updatedAt).toBeDefined();
});
it('ensure updatedAt field for last deleted folder', async () => {
// ARRANGE
const user = mock<User>();
user.role = 'global:admin';
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]);
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([]);
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
tagRepository.find.mockResolvedValue([]);
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
tags: [],
mappings: [],
});
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
tags: [],
mappings: [],
});
// Define a folder that does only exist remotely.
// Pushing this means it was deleted.
folderRepository.find.mockResolvedValue([]);
sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
folders: [
{
id: 'test-folder',
name: 'test folder name',
homeProjectId: 'some-id',
parentFolderId: null,
createdAt: '',
updatedAt: '',
},
],
});
sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({
folders: [],
});
// ACT
const pushResult = await sourceControlService.getStatus(user, {
direction: 'push',
verbose: false,
preferLocalVersion: false,
});
// ASSERT
if (!Array.isArray(pushResult)) {
fail('Expected pushResult to be an array.');
}
expect(pushResult).toHaveLength(1);
expect(pushResult.find((i) => i.type === 'folders')?.updatedAt).toBeDefined();
});
it('conflict depends on the value of `direction`', async () => { it('conflict depends on the value of `direction`', async () => {
// ARRANGE // ARRANGE
const user = mock<User>(); const user = mock<User>();

View File

@@ -869,6 +869,8 @@ export class SourceControlService {
select: ['updatedAt'], select: ['updatedAt'],
}); });
const lastUpdatedDate = lastUpdatedTag[0]?.updatedAt ?? new Date();
const tagMappingsRemote = const tagMappingsRemote =
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context); await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context);
const tagMappingsLocal = const tagMappingsLocal =
@@ -916,7 +918,7 @@ export class SourceControlService {
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: false, conflict: false,
file: getTagsPath(this.gitFolder), file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), updatedAt: lastUpdatedDate.toISOString(),
}); });
}); });
tagsMissingInRemote.forEach((item) => { tagsMissingInRemote.forEach((item) => {
@@ -928,7 +930,7 @@ export class SourceControlService {
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: options.direction === 'push' ? false : true, conflict: options.direction === 'push' ? false : true,
file: getTagsPath(this.gitFolder), file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), updatedAt: lastUpdatedDate.toISOString(),
}); });
}); });
@@ -941,7 +943,7 @@ export class SourceControlService {
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: true, conflict: true,
file: getTagsPath(this.gitFolder), file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), updatedAt: lastUpdatedDate.toISOString(),
}); });
}); });
@@ -1028,7 +1030,7 @@ export class SourceControlService {
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: true, conflict: true,
file: getFoldersPath(this.gitFolder), file: getFoldersPath(this.gitFolder),
updatedAt: lastUpdatedFolder[0]?.updatedAt.toISOString(), updatedAt: lastUpdatedDate.toISOString(),
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { inDevelopment, inProduction, LicenseState } from '@n8n/backend-common'; import { inDevelopment, inProduction } from '@n8n/backend-common';
import { SecurityConfig } from '@n8n/config'; import { SecurityConfig } from '@n8n/config';
import { Time } from '@n8n/constants'; import { Time } from '@n8n/constants';
import type { APIRequest } from '@n8n/db'; import type { APIRequest } from '@n8n/db';
@@ -77,7 +77,6 @@ export class Server extends AbstractServer {
private readonly postHogClient: PostHogClient, private readonly postHogClient: PostHogClient,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly licenseState: LicenseState,
) { ) {
super(); super();