mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 14:17:51 +02:00
feat(shared-stores): add createArchiveOps factory + unify archive pattern
Standardize two-stage deletion across modules: archive (isArchived) + soft-delete (deletedAt). Add shared factory, Archivable type mixin, filterActive/filterArchived/filterNotDeleted query helpers. 13 tests. Migrate memoro, chat, picture, contacts to use createArchiveOps. Standardize picture from archivedAt timestamp to isArchived boolean. Picture toggleFavorite now uses shared toggleField (1 param, not 2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d3114df16
commit
779a8ba322
12 changed files with 324 additions and 112 deletions
144
packages/shared-stores/src/archive.test.ts
Normal file
144
packages/shared-stores/src/archive.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createArchiveOps, filterActive, filterArchived, filterNotDeleted } from './archive';
|
||||
|
||||
function createMockTable(records: Record<string, Record<string, unknown>>) {
|
||||
return {
|
||||
get: vi.fn(async (id: string) => records[id] ?? null),
|
||||
update: vi.fn(async (id: string, changes: Record<string, unknown>) => {
|
||||
if (records[id]) Object.assign(records[id], changes);
|
||||
return 1;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Query Helpers ────────────────────────────────────────
|
||||
|
||||
describe('filterActive', () => {
|
||||
it('returns items that are neither archived nor deleted', () => {
|
||||
const items = [
|
||||
{ id: '1', isArchived: false, deletedAt: null },
|
||||
{ id: '2', isArchived: true, deletedAt: null },
|
||||
{ id: '3', isArchived: false, deletedAt: '2024-01-01' },
|
||||
{ id: '4', isArchived: true, deletedAt: '2024-01-01' },
|
||||
];
|
||||
expect(filterActive(items).map((i) => i.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('treats undefined isArchived as false', () => {
|
||||
const items = [{ id: '1', deletedAt: null }];
|
||||
expect(filterActive(items)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterArchived', () => {
|
||||
it('returns only archived, non-deleted items', () => {
|
||||
const items = [
|
||||
{ id: '1', isArchived: false, deletedAt: null },
|
||||
{ id: '2', isArchived: true, deletedAt: null },
|
||||
{ id: '3', isArchived: true, deletedAt: '2024-01-01' },
|
||||
];
|
||||
expect(filterArchived(items).map((i) => i.id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterNotDeleted', () => {
|
||||
it('returns items without deletedAt', () => {
|
||||
const items = [{ id: '1', deletedAt: null }, { id: '2', deletedAt: '2024-01-01' }, { id: '3' }];
|
||||
expect(filterNotDeleted(items).map((i) => i.id)).toEqual(['1', '3']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Archive Ops Factory ──────────────────────────────────
|
||||
|
||||
describe('createArchiveOps', () => {
|
||||
let records: Record<string, Record<string, unknown>>;
|
||||
let mockTable: ReturnType<typeof createMockTable>;
|
||||
let ops: ReturnType<typeof createArchiveOps>;
|
||||
|
||||
beforeEach(() => {
|
||||
records = {
|
||||
'1': { id: '1', isArchived: false, deletedAt: null },
|
||||
};
|
||||
mockTable = createMockTable(records);
|
||||
ops = createArchiveOps({ table: () => mockTable as never });
|
||||
});
|
||||
|
||||
describe('archive', () => {
|
||||
it('sets isArchived to true', async () => {
|
||||
await ops.archive('1');
|
||||
expect(mockTable.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({ isArchived: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets updatedAt', async () => {
|
||||
await ops.archive('1');
|
||||
const call = mockTable.update.mock.calls[0][1];
|
||||
expect(call.updatedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchive', () => {
|
||||
it('sets isArchived to false', async () => {
|
||||
records['1'].isArchived = true;
|
||||
await ops.unarchive('1');
|
||||
expect(mockTable.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({ isArchived: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleArchive', () => {
|
||||
it('toggles false to true', async () => {
|
||||
const result = await ops.toggleArchive('1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles true to false', async () => {
|
||||
records['1'].isArchived = true;
|
||||
const result = await ops.toggleArchive('1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('throws if record not found', async () => {
|
||||
await expect(ops.toggleArchive('missing')).rejects.toThrow('Record missing not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('sets deletedAt timestamp', async () => {
|
||||
await ops.softDelete('1');
|
||||
expect(mockTable.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({
|
||||
deletedAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('clears deletedAt', async () => {
|
||||
records['1'].deletedAt = '2024-01-01';
|
||||
await ops.restore('1');
|
||||
expect(mockTable.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({ deletedAt: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom archive field', () => {
|
||||
it('uses custom field name', async () => {
|
||||
const customOps = createArchiveOps({
|
||||
table: () => mockTable as never,
|
||||
archiveField: 'hidden',
|
||||
});
|
||||
await customOps.archive('1');
|
||||
expect(mockTable.update).toHaveBeenCalledWith('1', expect.objectContaining({ hidden: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue