968161b42b
The documents bucket SELECT policy only covers the uploader's own folder
(documents/{uid}/...), but document_attachments rows are company-scoped.
Every surface that touched storage with the user-bound client therefore
failed for attachments uploaded by another member of the same company
(colleague uploads, email-inbox ingest attributed to the company creator):
- GET /api/documents/:id 500ed with "Failed to create download URL", so
viewing a bilaga on a verifikat or supplier invoice was broken for
every member except the uploader (support case: Odin Aero, where all
40 documents live in the owner's folder and the second member could
open none of them).
- GET /api/documents/:id/integrity 500ed the same way.
- POST /api/documents/:id/verify failed the storage download.
- invoice-inbox retry-extraction could not download the attachment.
- cloud-backup user-triggered syncs silently dropped colleague-uploaded
documents from the Drive archive (manifest rows flipped to 'error').
Fix: authorize on the user client (RLS + explicit company filter, plus
the membership check where present), then do the storage read with the
service-role client. This is the pattern the inline proxy route and the
v1 download route already use; these five call sites were left behind.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { createHash } from 'node:crypto'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('@/lib/reports/full-archive-export', () => ({
|
|
generateFullArchive: vi.fn(),
|
|
generateBaseDataArchive: vi.fn(),
|
|
ARCHIVE_OVERHEAD_BYTES: 8 * 1024 * 1024,
|
|
}))
|
|
|
|
vi.mock('@/lib/reports/archive-readme', () => ({
|
|
buildDriveFolderReadme: vi.fn().mockReturnValue('README TEXT'),
|
|
}))
|
|
|
|
// performSync builds archives with a service-role client (the documents
|
|
// bucket SELECT policy is per-uploader-folder, so a user-bound client would
|
|
// drop colleague-uploaded files from the backup). The archive generators are
|
|
// mocked above, so a bare stub is enough here.
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: vi.fn(() => ({ storage: { from: vi.fn() } })),
|
|
}))
|
|
|
|
vi.mock('@/lib/branding/service', () => ({
|
|
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.test' }),
|
|
}))
|
|
|
|
vi.mock('../google-oauth', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../google-oauth')>()
|
|
return {
|
|
...actual,
|
|
getOAuthEnv: vi.fn().mockReturnValue({
|
|
clientId: 'client-id',
|
|
clientSecret: 'client-secret',
|
|
redirectUri: 'https://app.test/callback',
|
|
}),
|
|
refreshAccessToken: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('../google-drive', () => {
|
|
class DriveFileGoneError extends Error {}
|
|
return {
|
|
ensureFolder: vi.fn(),
|
|
uploadFile: vi.fn(),
|
|
updateFile: vi.fn(),
|
|
getFileMeta: vi.fn(),
|
|
DriveFileGoneError,
|
|
}
|
|
})
|
|
|
|
vi.mock('../crypto', () => ({
|
|
decryptToken: vi.fn().mockReturnValue('plain-refresh-token'),
|
|
}))
|
|
|
|
import {
|
|
performSync,
|
|
CONNECTION_KEY,
|
|
LAST_SYNC_KEY,
|
|
ARCHIVE_FORMAT_VERSION,
|
|
SIZE_LIMIT_BYTES,
|
|
arkivFileName,
|
|
} from '../sync'
|
|
import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth'
|
|
import {
|
|
DriveFileGoneError,
|
|
ensureFolder,
|
|
getFileMeta,
|
|
updateFile,
|
|
uploadFile,
|
|
} from '../google-drive'
|
|
import {
|
|
generateFullArchive,
|
|
generateBaseDataArchive,
|
|
} from '@/lib/reports/full-archive-export'
|
|
import type { GoogleDriveConnection, GoogleDriveLastSync } from '../../types'
|
|
|
|
const mockRefreshAccessToken = vi.mocked(refreshAccessToken)
|
|
const mockEnsureFolder = vi.mocked(ensureFolder)
|
|
const mockGetFileMeta = vi.mocked(getFileMeta)
|
|
const mockUploadFile = vi.mocked(uploadFile)
|
|
const mockUpdateFile = vi.mocked(updateFile)
|
|
const mockGenerateFullArchive = vi.mocked(generateFullArchive)
|
|
const mockGenerateBaseDataArchive = vi.mocked(generateBaseDataArchive)
|
|
|
|
function makeConnection(
|
|
overrides: Partial<GoogleDriveConnection> = {}
|
|
): GoogleDriveConnection {
|
|
return {
|
|
refresh_token_encrypted: 'encrypted-token',
|
|
account_email: 'user@example.com',
|
|
connected_at: '2026-01-01T00:00:00.000Z',
|
|
root_folder_id: 'root-1',
|
|
company_folder_id: 'company-1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const PERIOD = { id: 'p-2024', period_start: '2024-01-01', period_end: '2024-12-31' }
|
|
const ENTRY = { id: 'e-1', fiscal_period_id: 'p-2024', updated_at: '2024-06-01T00:00:00Z' }
|
|
const AUDIT_AT = '2026-07-01T00:00:00Z'
|
|
|
|
interface MockData {
|
|
connection?: GoogleDriveConnection | null
|
|
lastSync?: GoogleDriveLastSync | null
|
|
periods?: (typeof PERIOD)[]
|
|
entries?: (typeof ENTRY)[]
|
|
docs?: {
|
|
id: string
|
|
journal_entry_id: string | null
|
|
file_size_bytes: number | null
|
|
created_at: string | null
|
|
}[]
|
|
auditAt?: string
|
|
}
|
|
|
|
/**
|
|
* Table-routed supabase stub: performSync touches extension_data (connection,
|
|
* last-sync), company_settings, fiscal_periods, journal_entries,
|
|
* document_attachments and audit_log. Thenable chains resolve per table.
|
|
*/
|
|
function makeSupabase(data: MockData) {
|
|
const upsert = vi.fn().mockResolvedValue({ error: null })
|
|
const from = vi.fn().mockImplementation((table: string) => {
|
|
let key: string | null = null
|
|
const chain: any = {
|
|
upsert,
|
|
maybeSingle: vi.fn().mockImplementation(() => {
|
|
if (table === 'extension_data') {
|
|
if (key === 'google_drive_last_sync') {
|
|
return Promise.resolve({
|
|
data: data.lastSync ? { value: data.lastSync } : null,
|
|
})
|
|
}
|
|
return Promise.resolve({
|
|
data: data.connection ? { value: data.connection } : null,
|
|
})
|
|
}
|
|
if (table === 'company_settings') {
|
|
return Promise.resolve({
|
|
data: { company_name: 'Testbolag AB', org_number: '556000-0000' },
|
|
})
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
}),
|
|
then: (resolve: (v: unknown) => void) => {
|
|
if (table === 'fiscal_periods') {
|
|
return resolve({ data: data.periods ?? [PERIOD], error: null })
|
|
}
|
|
if (table === 'journal_entries') {
|
|
return resolve({ data: data.entries ?? [ENTRY], error: null })
|
|
}
|
|
if (table === 'document_attachments') {
|
|
return resolve({ data: data.docs ?? [], error: null })
|
|
}
|
|
if (table === 'audit_log') {
|
|
return resolve({
|
|
data: [{ created_at: data.auditAt ?? AUDIT_AT }],
|
|
error: null,
|
|
})
|
|
}
|
|
return resolve({ data: [], error: null })
|
|
},
|
|
}
|
|
const passthrough = ['select', 'eq', 'neq', 'in', 'order', 'range', 'limit']
|
|
for (const method of passthrough) {
|
|
chain[method] = vi.fn().mockImplementation((col?: string, val?: string) => {
|
|
if (method === 'eq' && col === 'key') key = val ?? null
|
|
return chain
|
|
})
|
|
}
|
|
return chain
|
|
})
|
|
return { supabase: { from } as any, from, upsert }
|
|
}
|
|
|
|
function syncParams(supabase: any, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
supabase,
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
origin: 'https://app.test',
|
|
includeDocuments: true,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function sha256(data: ArrayBuffer | string): string {
|
|
return createHash('sha256')
|
|
.update(typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data))
|
|
.digest('hex')
|
|
}
|
|
|
|
// Fingerprints performSync derives from the fixture above.
|
|
const PERIOD_FP = `v${ARCHIVE_FORMAT_VERSION}|1|${ENTRY.updated_at}|0||docs:1`
|
|
const BASE_FP = `v${ARCHIVE_FORMAT_VERSION}|${AUDIT_AT}|0||docs:1`
|
|
const README_FP = `v${ARCHIVE_FORMAT_VERSION}|${sha256('README TEXT').slice(0, 16)}`
|
|
|
|
function upToDateLastSync(): GoogleDriveLastSync {
|
|
return {
|
|
at: '2026-07-11T03:00:00.000Z',
|
|
folder_id: 'company-1',
|
|
files: [
|
|
{
|
|
kind: 'period',
|
|
period_id: PERIOD.id,
|
|
file_id: 'drive-period',
|
|
file_name: 'Arkiv 2024.zip',
|
|
size_bytes: 100,
|
|
fingerprint: PERIOD_FP,
|
|
sha256: 'x',
|
|
included_documents: true,
|
|
uploaded_at: '2026-07-11T03:00:00.000Z',
|
|
},
|
|
{
|
|
kind: 'base',
|
|
file_id: 'drive-base',
|
|
file_name: 'Grunddata.zip',
|
|
size_bytes: 50,
|
|
fingerprint: BASE_FP,
|
|
sha256: 'y',
|
|
included_documents: true,
|
|
uploaded_at: '2026-07-11T03:00:00.000Z',
|
|
},
|
|
{
|
|
kind: 'readme',
|
|
file_id: 'drive-readme',
|
|
file_name: 'LÄSMIG.txt',
|
|
size_bytes: 11,
|
|
fingerprint: README_FP,
|
|
sha256: 'z',
|
|
included_documents: true,
|
|
uploaded_at: '2026-07-11T03:00:00.000Z',
|
|
},
|
|
],
|
|
total_size_bytes: 161,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetFileMeta.mockResolvedValue({ id: 'company-1', name: 'Testbolag', trashed: false })
|
|
mockRefreshAccessToken.mockResolvedValue({ access_token: 'fresh-token', expires_in: 3600 })
|
|
mockGenerateFullArchive.mockResolvedValue(new ArrayBuffer(8))
|
|
mockGenerateBaseDataArchive.mockResolvedValue(new ArrayBuffer(16))
|
|
mockUploadFile.mockImplementation(async (_t, _folder, name, data) => ({
|
|
id: `created-${name}`,
|
|
name,
|
|
size_bytes: (data as ArrayBuffer).byteLength,
|
|
web_view_link: `https://drive.google.com/file/d/created-${name}/view`,
|
|
}))
|
|
mockUpdateFile.mockImplementation(async (_t, fileId, data) => ({
|
|
id: fileId,
|
|
name: 'updated',
|
|
size_bytes: (data as ArrayBuffer).byteLength,
|
|
web_view_link: `https://drive.google.com/file/d/${fileId}/view`,
|
|
}))
|
|
})
|
|
|
|
describe('performSync needs_reauth handling', () => {
|
|
it('flags the connection needs_reauth when Google returns 400 invalid_grant', async () => {
|
|
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
|
|
mockRefreshAccessToken.mockRejectedValueOnce(
|
|
new GoogleTokenRefreshError(
|
|
400,
|
|
'{"error":"invalid_grant","error_description":"Token has been expired or revoked."}'
|
|
)
|
|
)
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result).toMatchObject({ ok: false, reason: 'needs_reauth' })
|
|
expect(upsert).toHaveBeenCalledTimes(1)
|
|
const [payload] = upsert.mock.calls[0]
|
|
expect(payload.key).toBe(CONNECTION_KEY)
|
|
expect(payload.value.status).toBe('needs_reauth')
|
|
expect(payload.value.needs_reauth_at).toEqual(expect.any(String))
|
|
expect(payload.value.account_email).toBe('user@example.com')
|
|
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
|
|
expect(mockUploadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rethrows transient refresh failures (5xx) without flagging', async () => {
|
|
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
|
|
mockRefreshAccessToken.mockRejectedValueOnce(
|
|
new GoogleTokenRefreshError(500, 'Internal Server Error')
|
|
)
|
|
|
|
await expect(performSync(syncParams(supabase))).rejects.toThrow(/500/)
|
|
expect(upsert).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('clears a stale needs_reauth flag after a successful refresh', async () => {
|
|
const { supabase, upsert } = makeSupabase({
|
|
connection: makeConnection({
|
|
status: 'needs_reauth',
|
|
needs_reauth_at: '2026-07-01T03:00:00.000Z',
|
|
}),
|
|
})
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
const connectionSave = upsert.mock.calls.find(
|
|
([payload]) => payload.key === CONNECTION_KEY
|
|
)
|
|
expect(connectionSave).toBeDefined()
|
|
expect(connectionSave![0].value.status).toBe('active')
|
|
})
|
|
|
|
it('returns not_connected when no connection exists', async () => {
|
|
const { supabase } = makeSupabase({ connection: null })
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result).toMatchObject({ ok: false, reason: 'not_connected' })
|
|
expect(mockRefreshAccessToken).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('performSync per-fiscal-year layout', () => {
|
|
it('ignores extension runtime state when fingerprinting Grunddata', async () => {
|
|
const { supabase, from } = makeSupabase({
|
|
connection: makeConnection(),
|
|
lastSync: upToDateLastSync(),
|
|
})
|
|
|
|
await performSync(syncParams(supabase))
|
|
|
|
const auditCallIndex = from.mock.calls.findIndex(([table]) => table === 'audit_log')
|
|
expect(auditCallIndex).toBeGreaterThanOrEqual(0)
|
|
const auditQuery = from.mock.results[auditCallIndex].value
|
|
expect(auditQuery.neq).toHaveBeenCalledWith('table_name', 'extension_data')
|
|
expect(auditQuery.order).toHaveBeenNthCalledWith(1, 'created_at', { ascending: false })
|
|
expect(auditQuery.order).toHaveBeenNthCalledWith(2, 'id', { ascending: false })
|
|
})
|
|
|
|
it('uploads one archive per period plus Grunddata and the folder README on first sync', async () => {
|
|
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.uploadedCount).toBe(3)
|
|
expect(result.skippedCount).toBe(0)
|
|
expect(result.webViewLink).toBe('https://drive.google.com/drive/folders/company-1')
|
|
expect(mockUploadFile).toHaveBeenCalledTimes(3)
|
|
const names = mockUploadFile.mock.calls.map((c) => c[2])
|
|
expect(names).toEqual(['Arkiv 2024.zip', 'Grunddata.zip', 'LÄSMIG.txt'])
|
|
|
|
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
|
|
// Progressive persistence: one snapshot per upload + the final one.
|
|
expect(lastSyncSaves.length).toBe(4)
|
|
const final = lastSyncSaves[lastSyncSaves.length - 1][0].value as GoogleDriveLastSync
|
|
expect(final.files).toHaveLength(3)
|
|
expect(final.total_size_bytes).toBe(8 + 16 + Buffer.from('README TEXT').length)
|
|
const periodFile = final.files!.find((f) => f.kind === 'period')!
|
|
expect(periodFile.fingerprint).toBe(PERIOD_FP)
|
|
expect(periodFile.sha256).toBe(sha256(new ArrayBuffer(8)))
|
|
expect(periodFile.included_documents).toBe(true)
|
|
})
|
|
|
|
it('skips everything when fingerprints are unchanged', async () => {
|
|
const { supabase, upsert } = makeSupabase({
|
|
connection: makeConnection(),
|
|
lastSync: upToDateLastSync(),
|
|
})
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.uploadedCount).toBe(0)
|
|
expect(result.skippedCount).toBe(3)
|
|
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
|
|
expect(mockGenerateBaseDataArchive).not.toHaveBeenCalled()
|
|
expect(mockUploadFile).not.toHaveBeenCalled()
|
|
expect(mockUpdateFile).not.toHaveBeenCalled()
|
|
// The final snapshot still refreshes `at` so the card shows recency.
|
|
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
|
|
expect(lastSyncSaves.length).toBe(1)
|
|
})
|
|
|
|
it('updates only the changed period file, in place', async () => {
|
|
const stale = upToDateLastSync()
|
|
stale.files![0].fingerprint = 'v2|0||0||docs:1' // period data changed since
|
|
const { supabase } = makeSupabase({
|
|
connection: makeConnection(),
|
|
lastSync: stale,
|
|
})
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.uploadedCount).toBe(1)
|
|
expect(result.skippedCount).toBe(2)
|
|
expect(mockUpdateFile).toHaveBeenCalledTimes(1)
|
|
expect(mockUpdateFile.mock.calls[0][1]).toBe('drive-period')
|
|
expect(mockUploadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('recreates a file the user deleted in Drive', async () => {
|
|
const stale = upToDateLastSync()
|
|
stale.files![0].fingerprint = 'changed'
|
|
const { supabase } = makeSupabase({
|
|
connection: makeConnection(),
|
|
lastSync: stale,
|
|
})
|
|
mockUpdateFile.mockRejectedValueOnce(new DriveFileGoneError('gone'))
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockUploadFile).toHaveBeenCalledTimes(1)
|
|
expect(mockUploadFile.mock.calls[0][2]).toBe('Arkiv 2024.zip')
|
|
})
|
|
|
|
it('re-uploads everything for legacy single-file last_sync records', async () => {
|
|
const { supabase } = makeSupabase({
|
|
connection: makeConnection(),
|
|
lastSync: {
|
|
at: '2026-07-01T00:00:00Z',
|
|
folder_id: 'company-1',
|
|
file_id: 'legacy',
|
|
file_name: 'arkiv_full_x.zip',
|
|
file_size_bytes: 123,
|
|
},
|
|
})
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.uploadedCount).toBe(3)
|
|
expect(mockUpdateFile).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('performSync size limits and document fallback', () => {
|
|
const hugeDoc = {
|
|
id: 'doc-huge',
|
|
journal_entry_id: 'e-1',
|
|
file_size_bytes: SIZE_LIMIT_BYTES,
|
|
created_at: '2024-07-01T00:00:00Z',
|
|
}
|
|
|
|
it('fails with archive_too_large when a period cannot fit and fallback is off', async () => {
|
|
const { supabase } = makeSupabase({
|
|
connection: makeConnection(),
|
|
docs: [hugeDoc],
|
|
})
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: 'archive_too_large',
|
|
size_limit_bytes: SIZE_LIMIT_BYTES,
|
|
})
|
|
expect(mockUploadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('builds the oversized period without documents when fallback is allowed', async () => {
|
|
const { supabase, upsert } = makeSupabase({
|
|
connection: makeConnection(),
|
|
docs: [hugeDoc],
|
|
})
|
|
|
|
const result = await performSync(
|
|
syncParams(supabase, { allowDocumentFallback: true })
|
|
)
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockGenerateFullArchive).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({ scope: 'period', include_documents: false })
|
|
)
|
|
// Grunddata is unaffected: it still carries its (small) documents.
|
|
expect(mockGenerateBaseDataArchive).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({ include_documents: true })
|
|
)
|
|
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
|
|
const final = lastSyncSaves[lastSyncSaves.length - 1][0].value as GoogleDriveLastSync
|
|
const periodFile = final.files!.find((f) => f.kind === 'period')!
|
|
expect(periodFile.included_documents).toBe(false)
|
|
expect(periodFile.fingerprint).toContain('docs:0')
|
|
})
|
|
})
|
|
|
|
describe('performSync folder revalidation', () => {
|
|
it('recreates the folder hierarchy when cached folders are trashed', async () => {
|
|
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
|
|
mockGetFileMeta
|
|
.mockResolvedValueOnce({ id: 'company-1', name: 'x', trashed: true })
|
|
.mockResolvedValueOnce({ id: 'root-1', name: 'gnubok', trashed: true })
|
|
mockEnsureFolder
|
|
.mockResolvedValueOnce({ id: 'root-2', name: 'gnubok' })
|
|
.mockResolvedValueOnce({ id: 'company-2', name: 'Testbolag AB (556000-0000)' })
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockEnsureFolder).toHaveBeenCalledTimes(2)
|
|
const connectionSave = upsert.mock.calls.find(([p]) => p.key === CONNECTION_KEY)
|
|
expect(connectionSave![0].value.root_folder_id).toBe('root-2')
|
|
expect(connectionSave![0].value.company_folder_id).toBe('company-2')
|
|
// Uploads target the recreated company folder.
|
|
expect(mockUploadFile.mock.calls[0][1]).toBe('company-2')
|
|
})
|
|
|
|
it('does not touch folders when the cached company folder is alive', async () => {
|
|
const { supabase } = makeSupabase({ connection: makeConnection() })
|
|
|
|
const result = await performSync(syncParams(supabase))
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect(mockGetFileMeta).toHaveBeenCalledTimes(1)
|
|
expect(mockEnsureFolder).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('arkivFileName', () => {
|
|
it('uses the year alone for calendar years', () => {
|
|
expect(arkivFileName(PERIOD)).toBe('Arkiv 2024.zip')
|
|
})
|
|
|
|
it('uses full dates for broken fiscal years', () => {
|
|
expect(
|
|
arkivFileName({ id: 'p', period_start: '2024-07-01', period_end: '2025-06-30' })
|
|
).toBe('Arkiv 2024-07-01_2025-06-30.zip')
|
|
})
|
|
})
|