fix(usage): report restricted gallery downloads in v3 instead of an always-true signal
gallery_downloads.configured was true on every installation with a gallery. allow_downloads ships true — column default in migration 037 and the create route both set it — and the snapshot asked "at least one gallery has it on". The fleet value was ~100% by construction and could not separate a deliberate configuration from an untouched one. v2 consented to that key under that description, so v2 keeps sending it unchanged. v3 replaces it with gallery_downloads_restricted: at least one gallery has downloads switched off, which is the only state of that column anyone actually decides. Same catalog position, so the disclosed capability count stays at 86; the frontend copy, the EN/DE catalog strings, the coverage inventory and FEATURE_COVERAGE.md follow. Done in v3 rather than a v4 because v3 is on main and in no release yet, so nobody has consented to it. The collector carries the same catalog and has to take this change before the release that ships v3. One guard for the window in which :main / :beta images already carried the old v3 catalog. A report queued under it fails local validation on this build, and deliver() left a locally invalid report pending for good, blocking every operation behind it. A report's payload is derived state, so deliver() now rebuilds it from the current snapshot in place and sends that. Packet ID and sequence are kept — a re-signed retry has to reuse them so a lost acknowledgement does not duplicate data — and reports only: a stale registration, deletion or command is a genuine conflict and keeps the existing handling. Tests: the v3 snapshot counts a switched-off gallery and ignores enabled ones, v2 still reports the old key with the old meaning, and a stale queued report goes out rebuilt under the same packet id while a valid one is sent untouched. Relates to issue 1308
This commit is contained in:
@@ -27,7 +27,7 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
|
||||
await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); });
|
||||
await db.schema.createTable('events', t => {
|
||||
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
|
||||
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft');
|
||||
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft'); t.boolean('allow_downloads');
|
||||
});
|
||||
await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); });
|
||||
await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); });
|
||||
@@ -126,6 +126,69 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
|
||||
expect((await snap({})).gallery_folders.configured).toBe(false);
|
||||
});
|
||||
|
||||
test('gallery_downloads_restricted counts galleries with downloads switched off, and v2 keeps its old key', async () => {
|
||||
// allow_downloads ships true, so the v2 key was true on every install
|
||||
// with a gallery. Only switching downloads off is a decision.
|
||||
const snap = version => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags: {}, used: new Set(), now, version });
|
||||
expect((await snap('usage.v3')).gallery_downloads_restricted).toEqual({ configured: false });
|
||||
expect(await snap('usage.v3')).not.toHaveProperty('gallery_downloads');
|
||||
await db('events').insert([{ allow_downloads: true }, { allow_downloads: true }]);
|
||||
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(false);
|
||||
expect((await snap('usage.v2')).gallery_downloads).toEqual({ configured: true });
|
||||
expect(await snap('usage.v2')).not.toHaveProperty('gallery_downloads_restricted');
|
||||
await db('events').insert({ allow_downloads: false });
|
||||
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(true);
|
||||
expect((await snap('usage.v2')).gallery_downloads.configured).toBe(true);
|
||||
});
|
||||
|
||||
test('a report queued under the replaced catalog is rebuilt in place, keeping its packet id', async () => {
|
||||
const identity = p.generateIdentity();
|
||||
const posted = [];
|
||||
const service = new UsageService(db, {
|
||||
now: () => now, secret: 'v3-test-only-secret'.repeat(3), endpoint: 'http://127.0.0.1:9/',
|
||||
fetch: async (_url, init) => { posted.push(JSON.parse(init.body).packet); throw new Error('collector unreachable'); },
|
||||
});
|
||||
service.binding = async () => 'b'.repeat(64);
|
||||
const report = (features) => ({
|
||||
picpeak_version: '1.0.0', report_date: '2026-09-05', generated_at: new Date(now).toISOString(),
|
||||
features, gallery_layouts: [], inventory: { galleries: 0, photos: 0 },
|
||||
});
|
||||
// The v3 catalog as it stood before gallery_downloads_restricted replaced gallery_downloads.
|
||||
const { gallery_downloads_restricted, ...rest } = p.emptyFeatures('usage.v3');
|
||||
const stale = { ...rest, gallery_downloads: gallery_downloads_restricted };
|
||||
const seed = (payload) => db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active', installation_id: identity.installation_id, public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: 'b'.repeat(64),
|
||||
sequence: 1, last_error: null, attempts: 3, next_attempt_at: now + 60_000,
|
||||
pending_packet: JSON.stringify(p.makePacket(identity, 'report', 2, payload, 'usage.v3')),
|
||||
});
|
||||
|
||||
await seed(report(stale));
|
||||
const queued = JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).pending_packet);
|
||||
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||
// Sent once, under the current catalog, as the same packet.
|
||||
expect(posted).toHaveLength(1);
|
||||
expect(posted[0].packet_id).toBe(queued.packet_id);
|
||||
expect(posted[0].sequence).toBe(2);
|
||||
expect(posted[0].payload.features).toHaveProperty('gallery_downloads_restricted');
|
||||
expect(posted[0].payload.features).not.toHaveProperty('gallery_downloads');
|
||||
// The rebuilt packet is what stays queued for the ordinary retry path.
|
||||
let row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
const retained = JSON.parse(row.pending_packet);
|
||||
expect(retained.packet_id).toBe(queued.packet_id);
|
||||
expect(retained.payload.features).toHaveProperty('gallery_downloads_restricted');
|
||||
expect(row.status).toBe('active');
|
||||
expect(row.last_error).toBe('DELIVERY_FAILED');
|
||||
|
||||
// Narrow: a report that still validates is sent as queued, payload untouched.
|
||||
await seed(report(p.emptyFeatures('usage.v3')));
|
||||
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||
expect(posted).toHaveLength(2);
|
||||
expect(posted[1].payload.report_date).toBe('2026-09-05');
|
||||
row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(JSON.parse(row.pending_packet).payload.report_date).toBe('2026-09-05');
|
||||
});
|
||||
|
||||
test('ML recognition is already represented without querying faces or results', async () => {
|
||||
await db('feature_flags').insert({ key: 'faces', value: true });
|
||||
await client.markUsed(['face_recognition']);
|
||||
|
||||
Reference in New Issue
Block a user