/**
 * Token rotation + reuse detection with the repository mocked (PLANNING.md §8,
 * §12). The security property under test: presenting an already-rotated refresh
 * token burns the whole family.
 */
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('./auth.repository', () => ({
  findRefreshByHash: vi.fn(),
  insertRefreshToken: vi.fn().mockResolvedValue('successor-id'),
  markRefreshRotated: vi.fn().mockResolvedValue(undefined),
  revokeRefreshFamily: vi.fn().mockResolvedValue(undefined),
  revokeRefreshByHash: vi.fn().mockResolvedValue(undefined),
  revokeAllRefreshForUser: vi.fn().mockResolvedValue(undefined),
}));

import { findRefreshByHash, markRefreshRotated, revokeRefreshFamily } from './auth.repository';
import { rotateRefreshToken, signAccessToken, verifyAccessToken } from './token.service';

const sqlTime = (ms: number) =>
  new Date(Date.now() + ms).toISOString().slice(0, 19).replace('T', ' ');

const liveRow = () => ({
  id: 'old-id',
  admin_user_id: 'u1',
  family_id: 'fam-1',
  token_hash: 'hash',
  expires_at: sqlTime(24 * 3600 * 1000),
  revoked_at: null as string | null,
  replaced_by: null as string | null,
});

beforeEach(() => vi.clearAllMocks());

describe('access token', () => {
  it('round-trips subject and role', () => {
    const t = signAccessToken({ id: 'admin-9', role: 'superadmin' });
    expect(verifyAccessToken(t)).toEqual({ sub: 'admin-9', role: 'superadmin' });
  });

  it('rejects a bad token', () => {
    expect(() => verifyAccessToken('not.a.jwt')).toThrow();
  });
});

describe('rotateRefreshToken', () => {
  it('issues a successor and revokes the presented token on a valid rotation', async () => {
    vi.mocked(findRefreshByHash).mockResolvedValue(liveRow());
    const out = await rotateRefreshToken('raw-token', {});
    expect(out.adminUserId).toBe('u1');
    expect(typeof out.raw).toBe('string');
    expect(markRefreshRotated).toHaveBeenCalledWith('old-id', 'successor-id');
    expect(revokeRefreshFamily).not.toHaveBeenCalled();
  });

  it('detects reuse of a rotated token and burns the family', async () => {
    vi.mocked(findRefreshByHash).mockResolvedValue({ ...liveRow(), replaced_by: 'newer' });
    await expect(rotateRefreshToken('raw-token', {})).rejects.toMatchObject({ status: 401 });
    expect(revokeRefreshFamily).toHaveBeenCalledWith('fam-1');
  });

  it('detects reuse of a revoked token and burns the family', async () => {
    vi.mocked(findRefreshByHash).mockResolvedValue({ ...liveRow(), revoked_at: sqlTime(-1000) });
    await expect(rotateRefreshToken('raw-token', {})).rejects.toMatchObject({ status: 401 });
    expect(revokeRefreshFamily).toHaveBeenCalledWith('fam-1');
  });

  it('rejects an expired token without burning the family', async () => {
    vi.mocked(findRefreshByHash).mockResolvedValue({ ...liveRow(), expires_at: sqlTime(-60_000) });
    await expect(rotateRefreshToken('raw-token', {})).rejects.toMatchObject({ status: 401 });
    expect(revokeRefreshFamily).not.toHaveBeenCalled();
  });

  it('rejects an unknown token', async () => {
    vi.mocked(findRefreshByHash).mockResolvedValue(undefined);
    await expect(rotateRefreshToken('raw-token', {})).rejects.toMatchObject({ status: 401 });
  });
});
