Receiving users:list updated an in-memory array and did nothing else, so a person added to Officer next month reached Photos only if somebody remembered to re-run provisioning by hand. The platform already broadcasts on every create and delete, and re-sends on every sidecar registration — nothing was listening. Now it acts on it. A restart is therefore also a reconciliation, which is the cheap way to recover from any push that was missed while the sidecar was down. An automatic run is NOT allowed to key an adopted account. Keying one means resetting its password, because that is the only way Immich issues a key, and for an account that pre-dated Officer that takes away a login somebody is using — most likely in Immich's own app. Doing that unattended, to a person who did not know it was at stake, is not the same act as the owner choosing it: so the account is linked, left keyless, and named in `pending`. The deliberate /provision route still does the whole job. Tested against the case this is for: jrg.conceicao has an Immich library and no Officer account today. When that account exists, the plan adopts the library rather than building a second one beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
6.0 KiB
TypeScript
153 lines
6.0 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
||
import { normalize, planReconcile, type ImmichUser } from './reconcile';
|
||
import type { ProvisionableUser } from 'officerdb';
|
||
|
||
const user = (id: number, username: string, email: string): ProvisionableUser =>
|
||
({ id, username, email, name: null, role: 'Member' }) as ProvisionableUser;
|
||
|
||
// The real populations on alpha as of 2026-08-21, read from `GET /api/admin/users` and from
|
||
// `listProvisionableUsers()`. Real names rather than invented ones because every interesting case is
|
||
// already present here, and because a test built on the real overlap fails when the real overlap changes.
|
||
const PLATFORM = [
|
||
user(24, 'conceiba', 'conceiba@gmail.com'),
|
||
user(25, 'guipinto', 'tatudoemcampo@gmail.com'),
|
||
user(26, 'fedra', 'fedrasantamaria@gmail.com'),
|
||
user(27, 'diogo', 'dingoshf@gmail.com'),
|
||
];
|
||
|
||
const ADMIN_ID = '0e0b05c7';
|
||
|
||
const IMMICH: ImmichUser[] = [
|
||
{ id: ADMIN_ID, email: 'pastilhas@officer.dev', name: 'André Padez' }, // the connection's own account
|
||
{ id: 'aaaa1111', email: 'conceiba@gmail.com', name: 'Bruno Conceição' },
|
||
{ id: 'bbbb2222', email: 'fedrasantamaria@gmail.com', name: 'Fedra' },
|
||
{ id: 'cccc3333', email: 'jrg.conceicao@gmail.com', name: 'Jorge Conceição' }, // no Officer account
|
||
];
|
||
|
||
describe('the live instance, first run', () => {
|
||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing: [], adminImmichUserId: ADMIN_ID });
|
||
|
||
test('links the two whose email already exists', () => {
|
||
expect(plan.link.map((l) => l.user.email).sort()).toEqual(['conceiba@gmail.com', 'fedrasantamaria@gmail.com']);
|
||
expect(plan.link.find((l) => l.user.id === 24)!.immich.id).toBe('aaaa1111');
|
||
});
|
||
|
||
test('creates the two with no Immich account', () => {
|
||
expect(plan.create.map((u) => u.email).sort()).toEqual(['dingoshf@gmail.com', 'tatudoemcampo@gmail.com']);
|
||
});
|
||
|
||
test('leaves the account nobody on Officer owns, and never the admin', () => {
|
||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||
});
|
||
|
||
// The bug the Jellyfin plugin shipped and had to fix in 591c0da: the owner is excluded from the
|
||
// provisionable list on purpose, which made their own account read as belonging to nobody.
|
||
test('reports the admin as itself rather than as unclaimed', () => {
|
||
expect(plan.self?.email).toBe('pastilhas@officer.dev');
|
||
expect(plan.leave.map((u) => u.email)).not.toContain('pastilhas@officer.dev');
|
||
});
|
||
});
|
||
|
||
describe('a second run', () => {
|
||
const existing = [
|
||
{ userId: 24, immichUserId: 'aaaa1111' },
|
||
{ userId: 26, immichUserId: 'bbbb2222' },
|
||
];
|
||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing, adminImmichUserId: ADMIN_ID });
|
||
|
||
test('re-decides nothing that is already recorded', () => {
|
||
expect(plan.link).toHaveLength(0);
|
||
expect(plan.unchanged).toHaveLength(2);
|
||
});
|
||
|
||
test('still creates the two that are genuinely missing', () => {
|
||
expect(plan.create.map((u) => u.id).sort()).toEqual([25, 27]);
|
||
});
|
||
|
||
test('does not report a linked account as unclaimed', () => {
|
||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||
});
|
||
});
|
||
|
||
describe('matching', () => {
|
||
test('is case- and whitespace-insensitive, because Immich lowercases what it stores', () => {
|
||
const plan = planReconcile({
|
||
users: [user(30, 'shouty', ' FEDRASANTAMARIA@GMAIL.COM ')],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.create).toHaveLength(0);
|
||
expect(plan.link[0]!.immich.id).toBe('bbbb2222');
|
||
});
|
||
|
||
test('normalize agrees with what Immich does to an address', () => {
|
||
expect(normalize(' Fedra@Example.COM ')).toBe('fedra@example.com');
|
||
});
|
||
|
||
test('skips an Officer account with no email rather than inventing one', () => {
|
||
const plan = planReconcile({
|
||
users: [{ id: 31, username: 'nomail', email: '', name: null, role: 'Member' } as ProvisionableUser],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.create).toHaveLength(0);
|
||
expect(plan.link).toHaveLength(0);
|
||
});
|
||
|
||
test('two Officer accounts cannot both claim one Immich account', () => {
|
||
const plan = planReconcile({
|
||
users: [user(40, 'a', 'fedrasantamaria@gmail.com'), user(41, 'b', 'FedraSantamaria@gmail.com')],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.link).toHaveLength(1);
|
||
// The loser is not silently created either — that would duplicate an address Immich would refuse.
|
||
expect(plan.create).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// The planned case: an Immich account that exists today with no Officer login, whose person gets an
|
||
// Officer account later. Provisioning must adopt it rather than build a second library beside it.
|
||
describe('an Immich-only account whose owner joins Officer later', () => {
|
||
const joined = [...PLATFORM, user(28, 'jorge', 'jrg.conceicao@gmail.com')];
|
||
const plan = planReconcile({
|
||
users: joined,
|
||
immichUsers: IMMICH,
|
||
existing: [
|
||
{ userId: 24, immichUserId: 'aaaa1111' },
|
||
{ userId: 26, immichUserId: 'bbbb2222' },
|
||
],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
|
||
test('links them to the photographs they already have', () => {
|
||
expect(plan.link.map((l) => l.user.id)).toEqual([28]);
|
||
expect(plan.link[0]!.immich.id).toBe('cccc3333');
|
||
});
|
||
|
||
test('does not create a second account for the same address', () => {
|
||
expect(plan.create.map((u) => u.email)).not.toContain('jrg.conceicao@gmail.com');
|
||
});
|
||
|
||
test('stops reporting them as belonging to nobody', () => {
|
||
expect(plan.leave).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('an account deleted in Immich behind Officer’s back', () => {
|
||
const plan = planReconcile({
|
||
users: PLATFORM,
|
||
immichUsers: IMMICH,
|
||
existing: [{ userId: 25, immichUserId: 'gone9999' }],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
|
||
test('is reported as broken rather than quietly recreated', () => {
|
||
expect(plan.brokenLinks).toEqual([{ userId: 25, immichUserId: 'gone9999' }]);
|
||
expect(plan.create.map((u) => u.id)).not.toContain(25);
|
||
});
|
||
});
|