import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import {
  DEFAULT_PET_SETTINGS,
  PetConflictError,
  PetValidationError,
  slugifyPetId,
  readPetSettings,
  writePetSettings,
  listPets,
  getPet,
  importPetZip,
  deletePet,
  resolvePetAsset,
} from './pets.js';
import { ensureUserFolder, userRoot } from './paths.js';

// ── Fixtures ──────────────────────────────────────────────────────────────────

let root: string;
const USER = 'test-user';

function petsDir(): string {
  return join(userRoot(root, USER), 'pets');
}

function settingsFile(): string {
  return join(userRoot(root, USER), 'pet-settings.json');
}

function trashDir(): string {
  return join(userRoot(root, USER), 'trash');
}

/** Create a pet directory on disk with a manifest and optional extra files. */
function makePetDir(
  petId: string,
  manifest: Record<string, unknown>,
  files: Record<string, Buffer | string> = {},
): void {
  const dir = join(petsDir(), petId);
  mkdirSync(dir, { recursive: true });
  writeFileSync(join(dir, 'pet.json'), JSON.stringify(manifest));
  for (const [name, content] of Object.entries(files)) {
    writeFileSync(join(dir, name), content);
  }
}

/** Build an in-memory zip from a name → content map. */
function makeZip(files: Record<string, Buffer | string>): Buffer {
  const zip = new AdmZip();
  for (const [name, content] of Object.entries(files)) {
    zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content));
  }
  return zip.toBuffer();
}

beforeEach(() => {
  root = mkdtempSync(join(tmpdir(), 'pets-test-'));
  ensureUserFolder(root, USER);
});

afterEach(() => {
  rmSync(root, { recursive: true, force: true });
});

// ── slugifyPetId ──────────────────────────────────────────────────────────────

describe('slugifyPetId', () => {
  it('lowercases and replaces invalid characters with hyphens', () => {
    expect(slugifyPetId('My Cool Pet!')).toBe('my-cool-pet');
  });

  it('strips a trailing file extension', () => {
    expect(slugifyPetId('Sprite Cat.zip')).toBe('sprite-cat');
  });

  it('keeps already-valid ids unchanged', () => {
    expect(slugifyPetId('cat_01-x')).toBe('cat_01-x');
  });

  it('trims leading/trailing hyphens produced by replacement', () => {
    expect(slugifyPetId('  ---hello---  ')).toBe('hello');
  });

  it('truncates to 64 characters', () => {
    const out = slugifyPetId('a'.repeat(100));
    expect(out).toBe('a'.repeat(64));
  });

  it('falls back to a generated id for empty input', () => {
    expect(slugifyPetId('')).toMatch(/^pet-[a-z0-9]+$/);
    expect(slugifyPetId(null)).toMatch(/^pet-[a-z0-9]+$/);
    expect(slugifyPetId(undefined)).toMatch(/^pet-[a-z0-9]+$/);
  });

  it('falls back when input reduces to nothing', () => {
    expect(slugifyPetId('!!!')).toMatch(/^pet-[a-z0-9]+$/);
  });
});

// ── readPetSettings / writePetSettings ────────────────────────────────────────

describe('readPetSettings', () => {
  it('returns defaults when the settings file is missing', () => {
    const s = readPetSettings(root, USER);
    expect(s).toEqual(DEFAULT_PET_SETTINGS);
    // and it must be a copy, not the shared default object
    expect(s).not.toBe(DEFAULT_PET_SETTINGS);
  });

  it('reads back persisted settings', () => {
    writePetSettings(root, USER, { enabled: false, size: 48, activePetId: 'cat' });
    const s = readPetSettings(root, USER);
    expect(s.enabled).toBe(false);
    expect(s.size).toBe(48);
    expect(s.activePetId).toBe('cat');
  });

  it('quarantines a corrupted settings file to trash and returns defaults', () => {
    writeFileSync(settingsFile(), 'not json at all{');
    const s = readPetSettings(root, USER);
    expect(s).toEqual(DEFAULT_PET_SETTINGS);
    expect(existsSync(settingsFile())).toBe(false);
    const trashed = readdirSync(trashDir());
    expect(trashed.some(f => f.endsWith('-pet-settings.json'))).toBe(true);
  });

  it('quarantines a settings file with invalid values and returns defaults', () => {
    writeFileSync(settingsFile(), JSON.stringify({ size: 999 }));
    const s = readPetSettings(root, USER);
    expect(s).toEqual(DEFAULT_PET_SETTINGS);
    expect(existsSync(settingsFile())).toBe(false);
  });
});

describe('writePetSettings', () => {
  it('merges a patch over the previous settings', () => {
    writePetSettings(root, USER, { enabled: false });
    const s = writePetSettings(root, USER, { size: 32 });
    expect(s.enabled).toBe(false); // preserved from earlier write
    expect(s.size).toBe(32);
    expect(s.position).toBe('bottom-right');
  });

  it('persists valid JSON to disk', () => {
    writePetSettings(root, USER, { sound: true });
    const onDisk = JSON.parse(readFileSync(settingsFile(), 'utf-8'));
    expect(onDisk.sound).toBe(true);
  });

  it('rejects a non-object patch', () => {
    expect(() => writePetSettings(root, USER, null)).toThrow(PetValidationError);
    expect(() => writePetSettings(root, USER, [])).toThrow(PetValidationError);
    expect(() => writePetSettings(root, USER, 'x')).toThrow(PetValidationError);
  });

  it('rejects non-boolean values for boolean fields', () => {
    expect(() => writePetSettings(root, USER, { enabled: 1 })).toThrow(/enabled must be boolean/);
    expect(() => writePetSettings(root, USER, { sound: 'yes' })).toThrow(/sound must be boolean/);
    expect(() => writePetSettings(root, USER, { reducedMotion: 0 })).toThrow(/reducedMotion must be boolean/);
    expect(() => writePetSettings(root, USER, { toolSparkEnabled: 'on' })).toThrow(/toolSparkEnabled must be boolean/);
  });

  it('rejects an invalid size', () => {
    expect(() => writePetSettings(root, USER, { size: 100 })).toThrow(/size must be one of/);
    expect(() => writePetSettings(root, USER, { size: '64' })).toThrow(/size must be one of/);
  });

  it('accepts each allowed size', () => {
    for (const size of [32, 48, 64, 80] as const) {
      expect(writePetSettings(root, USER, { size }).size).toBe(size);
    }
  });

  it('rejects any position other than bottom-right', () => {
    expect(() => writePetSettings(root, USER, { position: 'top-left' })).toThrow(/position must be bottom-right/);
    expect(writePetSettings(root, USER, { position: 'bottom-right' }).position).toBe('bottom-right');
  });

  it('rejects an invalid activePetId', () => {
    expect(() => writePetSettings(root, USER, { activePetId: 'UPPER' })).toThrow(/activePetId is invalid/);
    expect(() => writePetSettings(root, USER, { activePetId: 42 })).toThrow(/activePetId must be string or null/);
  });

  it('normalizes empty-string activePetId to null', () => {
    writePetSettings(root, USER, { activePetId: 'cat' });
    const s = writePetSettings(root, USER, { activePetId: '' });
    expect(s.activePetId).toBeNull();
  });

  it('accepts null activePetId', () => {
    expect(writePetSettings(root, USER, { activePetId: null }).activePetId).toBeNull();
  });

  it('validates workerPets keys and values', () => {
    expect(() => writePetSettings(root, USER, { workerPets: { 'bad key!': 'cat' } }))
      .toThrow(/workerPets key is invalid/);
    expect(() => writePetSettings(root, USER, { workerPets: { worker1: 'BAD ID' } }))
      .toThrow(/must be a valid pet id/);
    expect(() => writePetSettings(root, USER, { workerPets: [] }))
      .toThrow(/workerPets must be an object/);
    expect(() => writePetSettings(root, USER, { workerPets: null }))
      .toThrow(/workerPets must be an object/);
  });

  it('drops workerPets entries with empty or null values (explicit removal)', () => {
    const s = writePetSettings(root, USER, {
      workerPets: { keep: 'cat', dropEmpty: '', dropNull: null },
    });
    expect(s.workerPets).toEqual({ keep: 'cat' });
  });

  it('replaces (not merges) the whole workerPets map', () => {
    writePetSettings(root, USER, { workerPets: { a: 'cat' } });
    const s = writePetSettings(root, USER, { workerPets: { b: 'dog' } });
    expect(s.workerPets).toEqual({ b: 'dog' });
  });

  it('rejects workerPets with too many entries', () => {
    const wp: Record<string, string> = {};
    for (let i = 0; i < 65; i++) wp[`worker-${i}`] = 'cat';
    expect(() => writePetSettings(root, USER, { workerPets: wp }))
      .toThrow(/too many entries/);
  });
});

// ── listPets / getPet ─────────────────────────────────────────────────────────

describe('listPets', () => {
  it('returns an empty array when there are no pets', () => {
    expect(listPets(root, USER)).toEqual([]);
  });

  it('lists pets sorted by display name and omits the manifest', () => {
    makePetDir('zebra', { name: 'Aardvark' });
    makePetDir('cat', { name: 'Whiskers' });
    const pets = listPets(root, USER);
    expect(pets.map(p => p.id)).toEqual(['zebra', 'cat']); // sorted by name, not id
    expect(pets.map(p => p.name)).toEqual(['Aardvark', 'Whiskers']);
    expect((pets[0] as Record<string, unknown>)['manifest']).toBeUndefined();
  });

  it('skips directories without a pet.json and ones with invalid ids', () => {
    makePetDir('valid', { name: 'Valid' });
    mkdirSync(join(petsDir(), 'no-manifest'), { recursive: true });
    mkdirSync(join(petsDir(), 'Invalid ID'), { recursive: true });
    const pets = listPets(root, USER);
    expect(pets.map(p => p.id)).toEqual(['valid']);
  });

  it('skips plain files in the pets directory', () => {
    writeFileSync(join(petsDir(), 'stray.txt'), 'x');
    expect(listPets(root, USER)).toEqual([]);
  });
});

describe('getPet', () => {
  it('returns null for an invalid pet id', () => {
    expect(getPet(root, USER, '../escape')).toBeNull();
    expect(getPet(root, USER, 'UPPER')).toBeNull();
  });

  it('returns null for a missing pet', () => {
    expect(getPet(root, USER, 'nope')).toBeNull();
  });

  it('prefers displayName over name over id', () => {
    makePetDir('p1', { displayName: 'Display', name: 'Plain' });
    makePetDir('p2', { name: 'Plain' });
    makePetDir('p3', {});
    expect(getPet(root, USER, 'p1')!.name).toBe('Display');
    expect(getPet(root, USER, 'p2')!.name).toBe('Plain');
    expect(getPet(root, USER, 'p3')!.name).toBe('p3');
  });

  it('picks sprite and preview files only when they exist on disk', () => {
    makePetDir(
      'p1',
      { spritesheet: 'sheet.png', preview: 'thumb.png' },
      { 'sheet.png': Buffer.from('png') },
    );
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.spriteFile).toBe('sheet.png');
    expect(pet.previewFile).toBeNull(); // thumb.png referenced but absent
  });

  it('falls back to conventional sprite/preview filenames', () => {
    makePetDir('p1', {}, {
      'spritesheet.webp': Buffer.from('w'),
      'preview.png': Buffer.from('p'),
    });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.spriteFile).toBe('spritesheet.webp');
    expect(pet.previewFile).toBe('preview.png');
  });

  it('resolves sprite from a nested manifest object with a file key', () => {
    makePetDir(
      'p1',
      { spritesheet: { file: 'art.png', frameWidth: 32, frameHeight: 32 } },
      { 'art.png': Buffer.from('x') },
    );
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.spriteFile).toBe('art.png');
    expect(pet.frameWidth).toBe(32);
    expect(pet.frameHeight).toBe(32);
  });

  it('reads frame dimensions and grid from top-level manifest keys', () => {
    makePetDir('p1', { frameWidth: 64, frameHeight: 48, gridCols: 4, gridRows: 2 });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.frameWidth).toBe(64);
    expect(pet.frameHeight).toBe(48);
    expect(pet.gridCols).toBe(4);
    expect(pet.gridRows).toBe(2);
  });

  it('supports snake_case frame keys', () => {
    makePetDir('p1', { frame_width: 16, frame_height: 24 });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.frameWidth).toBe(16);
    expect(pet.frameHeight).toBe(24);
  });

  it('nulls both frame dimensions when only one is given', () => {
    makePetDir('p1', { frameWidth: 64 });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.frameWidth).toBeNull();
    expect(pet.frameHeight).toBeNull();
  });

  it('ignores out-of-range or non-integer dimensions', () => {
    makePetDir('p1', { frameWidth: 0, frameHeight: 5000, gridCols: 1.5, gridRows: -1 });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.frameWidth).toBeNull();
    expect(pet.gridCols).toBeNull();
    expect(pet.gridRows).toBeNull();
  });

  it('exposes the full manifest and an ISO updatedAt', () => {
    makePetDir('p1', { name: 'X', custom: true });
    const pet = getPet(root, USER, 'p1')!;
    expect(pet.manifest).toEqual({ name: 'X', custom: true });
    expect(() => new Date(pet.updatedAt).toISOString()).not.toThrow();
  });

  it('throws PetValidationError for a corrupt manifest', () => {
    const dir = join(petsDir(), 'bad');
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, 'pet.json'), '{broken');
    expect(() => getPet(root, USER, 'bad')).toThrow(PetValidationError);
  });

  it('throws PetValidationError for an array manifest', () => {
    const dir = join(petsDir(), 'arr');
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, 'pet.json'), '[1,2]');
    expect(() => getPet(root, USER, 'arr')).toThrow(/manifest must be an object/);
  });
});

// ── importPetZip ──────────────────────────────────────────────────────────────

describe('importPetZip', () => {
  it('imports a root-level pet package', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({ name: 'Whiskers', frameWidth: 32, frameHeight: 32 }),
      'spritesheet.png': Buffer.from('fakepng'),
    });
    const detail = importPetZip(root, USER, zip);
    expect(detail.id).toBe('whiskers');
    expect(detail.name).toBe('Whiskers');
    expect(detail.spriteFile).toBe('spritesheet.png');
    expect(existsSync(join(petsDir(), 'whiskers', 'spritesheet.png'))).toBe(true);
  });

  it('strips a single base directory prefix', () => {
    const zip = makeZip({
      'mypet/pet.json': JSON.stringify({}),
      'mypet/spritesheet.png': Buffer.from('x'),
    });
    const detail = importPetZip(root, USER, zip);
    expect(detail.id).toBe('mypet'); // derived from the base dir name
    expect(detail.spriteFile).toBe('spritesheet.png');
  });

  it('uses manifest id over name over base dir for the pet id', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({ id: 'Custom-ID', name: 'Other' }),
    });
    expect(importPetZip(root, USER, zip).id).toBe('custom-id');
  });

  it('prefers options.preferredId over the manifest', () => {
    const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'from-manifest' }) });
    const detail = importPetZip(root, USER, zip, { preferredId: 'Forced Name' });
    expect(detail.id).toBe('forced-name');
  });

  it('throws PetConflictError on duplicate import without overwrite', () => {
    const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'dup' }) });
    importPetZip(root, USER, zip);
    expect(() => importPetZip(root, USER, zip)).toThrow(PetConflictError);
    try {
      importPetZip(root, USER, zip);
    } catch (err) {
      expect((err as PetConflictError).petId).toBe('dup');
    }
  });

  it('overwrites and trashes the previous version with overwrite: true', () => {
    importPetZip(root, USER, makeZip({
      'pet.json': JSON.stringify({ id: 'dup', name: 'Old' }),
    }));
    const detail = importPetZip(root, USER, makeZip({
      'pet.json': JSON.stringify({ id: 'dup', name: 'New' }),
    }), { overwrite: true });
    expect(detail.name).toBe('New');
    const trashed = readdirSync(trashDir());
    expect(trashed.some(f => f.endsWith('-dup-pet'))).toBe(true);
  });

  it('rejects an empty body and an oversized body without parsing', () => {
    expect(() => importPetZip(root, USER, Buffer.alloc(0))).toThrow(/zip body is empty/);
    expect(() => importPetZip(root, USER, Buffer.alloc(12 * 1024 * 1024 + 1)))
      .toThrow(/zip body is too large/);
  });

  it('rejects a zip without entries', () => {
    expect(() => importPetZip(root, USER, new AdmZip().toBuffer())).toThrow(/zip is empty/);
  });

  it('requires pet.json', () => {
    const zip = makeZip({ 'spritesheet.png': Buffer.from('x') });
    expect(() => importPetZip(root, USER, zip)).toThrow(/pet\.json is required/);
  });

  it('rejects path traversal entry names', () => {
    // adm-zip's addFile canonicalizes '../', so byte-patch a same-length
    // placeholder to get a genuinely hostile entry name into the archive.
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'AA/evil.json': '{}',
    });
    const patched = Buffer.from(
      zip.toString('latin1').split('AA/evil.json').join('../evil.json'),
      'latin1',
    );
    expect(() => importPetZip(root, USER, patched)).toThrow(/unsafe path/);
  });

  it('rejects dotfile entries', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      '.hidden.json': '{}',
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(/unsafe path/);
  });

  it('rejects unsupported file types', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'run.sh': 'echo pwned',
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(/unsupported file type/);
  });

  it('rejects nested files below the package root', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'sub/inner.png': Buffer.from('x'),
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(/nested files are not supported/);
  });

  it('rejects symlink entries', () => {
    const zip = new AdmZip();
    zip.addFile('pet.json', Buffer.from('{}'));
    zip.addFile('link.json', Buffer.from('/etc/passwd'));
    // addFile's attr argument is not preserved through toBuffer; set the unix
    // mode (high 16 bits, 0o120000 = symlink) directly on the entry header.
    const link = zip.getEntry('link.json');
    if (!link) throw new Error('fixture entry missing');
    link.header.attr = (0o120777 << 16) >>> 0;
    expect(() => importPetZip(root, USER, zip.toBuffer())).toThrow(/symlinks are not allowed/);
  });

  it('rejects too many files', () => {
    const files: Record<string, string> = { 'pet.json': '{}' };
    for (let i = 0; i < 32; i++) files[`extra-${i}.json`] = '{}'; // 33 total
    expect(() => importPetZip(root, USER, makeZip(files))).toThrow(/too many files/);
  });

  it('rejects a single file over 5 MB uncompressed', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'big.png': Buffer.alloc(5 * 1024 * 1024 + 1),
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(/file is too large/);
  });

  it('rejects total uncompressed size over 10 MB', () => {
    const fourMb = 4 * 1024 * 1024;
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'a.png': Buffer.alloc(fourMb),
      'b.png': Buffer.alloc(fourMb),
      'c.png': Buffer.alloc(fourMb),
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(/uncompressed size is too large/);
  });

  it('rejects a non-object manifest', () => {
    const zip = makeZip({ 'pet.json': '[1,2,3]' });
    expect(() => importPetZip(root, USER, zip)).toThrow(/manifest must be an object/);
  });

  it('cleans up the staging tmp dir on failure', () => {
    const zip = makeZip({
      'pet.json': JSON.stringify({}),
      'run.sh': 'x',
    });
    expect(() => importPetZip(root, USER, zip)).toThrow(PetValidationError);
    const leftovers = readdirSync(petsDir()).filter(n => n.startsWith('.tmp-'));
    expect(leftovers).toEqual([]);
  });
});

// ── deletePet ─────────────────────────────────────────────────────────────────

describe('deletePet', () => {
  it('returns false for an invalid or missing pet', () => {
    expect(deletePet(root, USER, '../escape')).toBe(false);
    expect(deletePet(root, USER, 'nope')).toBe(false);
  });

  it('moves the pet directory to trash and returns true', () => {
    makePetDir('cat', { name: 'Cat' });
    expect(deletePet(root, USER, 'cat')).toBe(true);
    expect(existsSync(join(petsDir(), 'cat'))).toBe(false);
    const trashed = readdirSync(trashDir());
    expect(trashed.some(f => f.endsWith('-cat-pet'))).toBe(true);
  });

  it('clears activePetId when the active pet is deleted', () => {
    makePetDir('cat', {});
    writePetSettings(root, USER, { activePetId: 'cat' });
    deletePet(root, USER, 'cat');
    expect(readPetSettings(root, USER).activePetId).toBeNull();
  });

  it('removes workerPets mappings pointing at the deleted pet, keeps others', () => {
    makePetDir('cat', {});
    makePetDir('dog', {});
    writePetSettings(root, USER, { workerPets: { w1: 'cat', w2: 'dog' } });
    deletePet(root, USER, 'cat');
    expect(readPetSettings(root, USER).workerPets).toEqual({ w2: 'dog' });
  });

  it('leaves settings untouched when the pet was not referenced', () => {
    makePetDir('cat', {});
    makePetDir('dog', {});
    writePetSettings(root, USER, { activePetId: 'dog', workerPets: { w1: 'dog' } });
    deletePet(root, USER, 'cat');
    const s = readPetSettings(root, USER);
    expect(s.activePetId).toBe('dog');
    expect(s.workerPets).toEqual({ w1: 'dog' });
  });
});

// ── resolvePetAsset ───────────────────────────────────────────────────────────

describe('resolvePetAsset', () => {
  beforeEach(() => {
    makePetDir('cat', { name: 'Cat' }, {
      'spritesheet.png': Buffer.from('png'),
      'preview.webp': Buffer.from('webp'),
    });
  });

  it('resolves existing files with the right content type', () => {
    const png = resolvePetAsset(root, USER, 'cat', 'spritesheet.png');
    expect(png).not.toBeNull();
    expect(png!.contentType).toBe('image/png');
    expect(png!.path).toBe(join(petsDir(), 'cat', 'spritesheet.png'));

    expect(resolvePetAsset(root, USER, 'cat', 'preview.webp')!.contentType).toBe('image/webp');
    expect(resolvePetAsset(root, USER, 'cat', 'pet.json')!.contentType)
      .toBe('application/json; charset=utf-8');
  });

  it('returns null for an invalid pet id', () => {
    expect(resolvePetAsset(root, USER, 'BAD ID', 'pet.json')).toBeNull();
  });

  it('returns null for unsafe file names', () => {
    expect(resolvePetAsset(root, USER, 'cat', '')).toBeNull();
    expect(resolvePetAsset(root, USER, 'cat', 'a/b.png')).toBeNull();
    expect(resolvePetAsset(root, USER, 'cat', 'a\\b.png')).toBeNull();
    expect(resolvePetAsset(root, USER, 'cat', '.hidden.png')).toBeNull();
    expect(resolvePetAsset(root, USER, 'cat', '..')).toBeNull();
  });

  it('returns null for disallowed extensions', () => {
    expect(resolvePetAsset(root, USER, 'cat', 'script.js')).toBeNull();
    expect(resolvePetAsset(root, USER, 'cat', 'noext')).toBeNull();
  });

  it('returns null when the file does not exist', () => {
    expect(resolvePetAsset(root, USER, 'cat', 'missing.png')).toBeNull();
  });
});
