Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,6 @@ dist
# Jest
jest_html_reporters.html
reports

# tmp files
tmp
2 changes: 2 additions & 0 deletions src/cleaner/storageProviders/fsStorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export class FsStorageProvider implements IStorageProvider {
const relativePath = paths[idx]!;
this.logger.warn({ msg: 'Failed to delete file', path: join(storageTarget, relativePath), error: result.reason });
failedPaths.push(relativePath);
} else if (result.value !== undefined) {
failedPaths.push(result.value);
}
}

Expand Down
1 change: 0 additions & 1 deletion src/cleaner/storageProviders/s3StorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { ConfigType } from '@common/config';
import type { IStorageProvider } from './iStorageProvider';

const S3_MAX_DELETE_BATCH = 1000;
const S3_ERROR_NO_SUCH_KEY = 'NoSuchKey';

interface S3Config {
endpoint: string;
Expand Down
6 changes: 3 additions & 3 deletions tests/errorHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { faker } from '@faker-js/faker';
import jsLogger from '@map-colonies/js-logger';
import { jsLogger } from '@map-colonies/js-logger';
import { toError, ErrorHandler, RecoverableError, UnrecoverableError, ValidationError } from '../src/cleaner/errors';
import type { ErrorContext } from '../src/cleaner/types';

describe('ErrorHandler', () => {
let errorHandler: ErrorHandler;

beforeEach(() => {
errorHandler = new ErrorHandler(jsLogger({ enabled: false }));
beforeEach(async () => {
errorHandler = new ErrorHandler(await jsLogger({ enabled: false }));
});

describe('handleError', () => {
Expand Down
50 changes: 50 additions & 0 deletions tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Logger } from '@map-colonies/js-logger';
import type { TaskHandler as QueueClient } from '@map-colonies/mc-priority-queue';
import type { ConfigType } from '../../src/common/config';
import type { ITaskStrategy, StrategyFactory } from '../../src/cleaner/strategies';
import type { IStorageProvider } from '../../src/cleaner/storageProviders';
import type { ErrorHandler } from '../../src/cleaner/errors';
import type { ErrorDecision, PollingPairConfig } from '../../src/cleaner/types';
import { TaskPoller } from '../../src/worker/taskPoller';
Expand Down Expand Up @@ -34,6 +35,7 @@ export function createMockQueueClient(): QueueClient {
dequeue: vi.fn().mockResolvedValue(null),
ack: vi.fn().mockResolvedValue(undefined),
reject: vi.fn().mockResolvedValue(undefined),
updateProgress: vi.fn().mockResolvedValue(undefined),
} as unknown as QueueClient;
}

Expand Down Expand Up @@ -61,6 +63,54 @@ export function createMockErrorHandler(defaultDecision: ErrorDecision = { should
} as unknown as ErrorHandler;
}

// ─── StorageProvider ─────────────────────────────────────────────────────────

export function createMockStorageProvider(): IStorageProvider {
return {
delete: vi.fn().mockResolvedValue([]),
targetExists: vi.fn().mockResolvedValue(true),
};
}

// ─── Strategy Config (TilesDeletionStrategy) ─────────────────────────────────

export const TILES_DELETION_CONFIG_DEFAULTS = {
batchSize: 100,
concurrency: 2,
failureSampleSize: 3,
s3Bucket: 'test-bucket',
fsBasePath: '/test/tiles',
} as const;

export function createMockStrategyConfig(overrides: Record<string, unknown> = {}): ConfigType {
const values: Record<string, unknown> = {
'strategies.tilesDeletion.batchSize': TILES_DELETION_CONFIG_DEFAULTS.batchSize,
'strategies.tilesDeletion.concurrency': TILES_DELETION_CONFIG_DEFAULTS.concurrency,
'strategies.tilesDeletion.failureSampleSize': TILES_DELETION_CONFIG_DEFAULTS.failureSampleSize,
'strategies.tilesDeletion.s3Bucket': TILES_DELETION_CONFIG_DEFAULTS.s3Bucket,
'strategies.tilesDeletion.fsBasePath': TILES_DELETION_CONFIG_DEFAULTS.fsBasePath,
...overrides,
};
return { get: vi.fn().mockImplementation((key: string) => values[key]) } as unknown as ConfigType;
}

// ─── S3 Storage Config (S3StorageProvider) ───────────────────────────────────

export const S3_STORAGE_CONFIG_DEFAULTS = {
endpoint: 'http://localhost:9000',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
sslEnabled: false,
forcePathStyle: true,
region: 'us-east-1',
} as const;

export function createMockS3Config(): ConfigType {
return {
get: vi.fn().mockReturnValue({ ...S3_STORAGE_CONFIG_DEFAULTS }),
} as unknown as ConfigType;
}

// ─── TaskPoller factory ───────────────────────────────────────────────────────

/**
Expand Down
166 changes: 166 additions & 0 deletions tests/storageProviders/fsStorageProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { stat, unlink, rmdir } from 'node:fs/promises';
import { join } from 'node:path';
import { Stats } from 'node:fs';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FsStorageProvider } from '@src/cleaner/storageProviders/fsStorageProvider';
import { createMockLogger } from '../helpers/mocks';

vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
unlink: vi.fn(),
rmdir: vi.fn(),
}));

const BASE_PATH = '/tiles/test';

describe('FsStorageProvider', () => {
let provider: FsStorageProvider;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stat).mockResolvedValue({} as Stats);
vi.mocked(unlink).mockResolvedValue(undefined);
vi.mocked(rmdir).mockResolvedValue(undefined);
provider = new FsStorageProvider(createMockLogger());
});

describe('targetExists', () => {
const RELATIVE_PATH = 'layer/v1';

it('should call stat with full target path', async () => {
const targetPath = join(BASE_PATH, RELATIVE_PATH);
const result = await provider.targetExists(BASE_PATH, RELATIVE_PATH);
Comment thread
CL-SHLOMIKONCHA marked this conversation as resolved.

expect(result).toBe(true);
expect(stat).toHaveBeenCalledWith(targetPath);
});

it('should return false when path does not exist (ENOENT)', async () => {
Comment thread
CL-SHLOMIKONCHA marked this conversation as resolved.
vi.mocked(stat).mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));

const result = await provider.targetExists(BASE_PATH, RELATIVE_PATH);

expect(result).toBe(false);
});

it('should throw an errors that are not ENOENT', async () => {
vi.mocked(stat).mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));

await expect(provider.targetExists(BASE_PATH, RELATIVE_PATH)).rejects.toThrow('EACCES');
});
});

describe('delete', () => {
it('should return empty array for empty input', async () => {
const result = await provider.delete([], BASE_PATH);
expect(result).toEqual([]);
expect(unlink).not.toHaveBeenCalled();
});

it('should call unlink with joined base path and relative path', async () => {
await provider.delete(['layer/v1/10/0/0.png'], BASE_PATH);

expect(unlink).toHaveBeenCalledWith(join(BASE_PATH, 'layer/v1/10/0/0.png'));
});

it('should call unlink for every path', async () => {
const paths = ['tile/10/0/0.png', 'tile/10/0/1.png', 'tile/10/1/0.png'];

await provider.delete(paths, BASE_PATH);

expect(unlink).toHaveBeenCalledTimes(3);
for (const p of paths) {
expect(unlink).toHaveBeenCalledWith(join(BASE_PATH, p));
}
});

it('should return empty array when all unlinks succeed', async () => {
const result = await provider.delete(['tile/10/0/0.png', 'tile/10/0/1.png'], BASE_PATH);
expect(result).toEqual([]);
});

it('should treat ENOENT as a failed deletion (included in failure report)', async () => {
const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(unlink).mockRejectedValue(enoent);

const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH);

expect(result).toEqual(['tile/10/0/0.png']);
});

it('should return failed path for non-ENOENT errors', async () => {
const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(unlink).mockRejectedValue(permError);

const result = await provider.delete(['tile/10/0/0.png'], BASE_PATH);

expect(result).toEqual(['tile/10/0/0.png']);
});

it('should handle mixed success, ENOENT and real errors', async () => {
const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' });

vi.mocked(unlink)
.mockResolvedValueOnce(undefined) // success
.mockRejectedValueOnce(enoent) // ENOENT → failure
.mockRejectedValueOnce(permError); // real error → failure

const paths = ['tile/10/0/0.png', 'tile/10/0/1.png', 'tile/10/0/2.png'];
const result = await provider.delete(paths, BASE_PATH);

expect(result).toEqual(['tile/10/0/1.png', 'tile/10/0/2.png']);
});

it('should use relative path as key in failed paths (not the full absolute path)', async () => {
const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(unlink).mockRejectedValue(permError);

const relativePath = 'layer/v1/10/5/3.png';
const result = await provider.delete([relativePath], BASE_PATH);

expect(result).toEqual([relativePath]);
expect(result[0]).not.toContain(BASE_PATH);
});

describe('cleanupEmptyDirs', () => {
it('should attempt to rmdir the parent directory after deletion', async () => {
await provider.delete(['layer/v1/10/0/0.png'], BASE_PATH);

expect(rmdir).toHaveBeenCalledWith(join(BASE_PATH, 'layer/v1/10/0'));
});

it('should attempt to rmdir all ancestor directories bottom-up', async () => {
await provider.delete(['layer/v1/10/0/0.png'], BASE_PATH);

// x dir → zoom dir → version dir → layer dir (deepest first)
expect(rmdir).toHaveBeenCalledWith(join(BASE_PATH, 'layer/v1/10/0'));
expect(rmdir).toHaveBeenCalledWith(join(BASE_PATH, 'layer/v1/10'));
expect(rmdir).toHaveBeenCalledWith(join(BASE_PATH, 'layer/v1'));
expect(rmdir).toHaveBeenCalledWith(join(BASE_PATH, 'layer'));
});

it('should deduplicate rmdir calls for shared parent directories', async () => {
// Both tiles share the same x-dir and zoom dir
await provider.delete(['tile/10/0/0.png', 'tile/10/0/1.png'], BASE_PATH);

const rmdirCalls = vi.mocked(rmdir).mock.calls.map(([p]) => p);
const xDirCalls = rmdirCalls.filter((p) => p === join(BASE_PATH, 'tile/10/0'));
expect(xDirCalls).toHaveLength(1);
});

it('should silently ignore rmdir failures (ENOTEMPTY)', async () => {
const enotempty = Object.assign(new Error('ENOTEMPTY'), { code: 'ENOTEMPTY' });
vi.mocked(rmdir).mockRejectedValue(enotempty);

// Should not throw and should return correct failed paths
await expect(provider.delete(['tile/10/0/0.png'], BASE_PATH)).resolves.toEqual([]);
});

it('should not call rmdir when input is empty', async () => {
await provider.delete([], BASE_PATH);
expect(rmdir).not.toHaveBeenCalled();
});
});
});
});
Loading
Loading