Skip to content
4 changes: 2 additions & 2 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -3314,7 +3314,7 @@ type Query {
vars: Vars!

"""Get information about all VMs on the system"""
vms: Vms!
vms: Vms
parityHistory: [ParityCheck!]!
array: UnraidArray!
customization: Customization
Expand All @@ -3326,7 +3326,7 @@ type Query {

"""Network interfaces"""
networkInterfaces: [InfoNetworkInterface!]!
docker: Docker!
docker: Docker
disks: [Disk!]!
assignableDisks: [Disk!]!
disk(id: PrefixedID!): Disk!
Expand Down
4 changes: 2 additions & 2 deletions api/src/unraid-api/cli/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2383,7 +2383,7 @@ export type Query = {
disk: Disk;
disks: Array<Disk>;
display: InfoDisplay;
docker: Docker;
docker?: Maybe<Docker>;
flash: Flash;
/** Get JSON Schema for API key creation form */
getApiKeyCreationFormSchema: ApiKeyFormSettings;
Expand Down Expand Up @@ -2447,7 +2447,7 @@ export type Query = {
validateOidcSession: OidcSessionValidation;
vars: Vars;
/** Get information about all VMs on the system */
vms: Vms;
vms?: Maybe<Vms>;
};


Expand Down
141 changes: 141 additions & 0 deletions api/src/unraid-api/graph/partial-results.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule, GraphQLSchemaHost, Query, Resolver } from '@nestjs/graphql';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { readFile } from 'fs/promises';

import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { buildSchema } from 'graphql';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';

@Resolver()
class ServerStatusResolver {
@Query(() => String)
serverStatus() {
return 'available';
}
}

type Result = {
data: Record<string, unknown> | null;
errors?: { path: (string | number)[] }[];
};

describe('Partial GraphQL results for unavailable services', () => {
let app: NestFastifyApplication;
const vms = { getDomains: vi.fn() };
const docker = { getNetworks: vi.fn() };

beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
}),
],
providers: [
PrefixedID,
ServerStatusResolver,
VmsResolver,
DockerResolver,
{ provide: VmsService, useValue: vms },
{ provide: DockerService, useValue: docker },
{
provide: SubscriptionTrackerService,
useValue: { registerTopic: vi.fn() },
},
],
})
.useMocker(() => ({}))
.compile();
app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});

beforeEach(() => {
vms.getDomains.mockReset().mockResolvedValue([]);
docker.getNetworks.mockReset().mockResolvedValue([]);
});

afterAll(async () => app?.close());

async function execute(query: string): Promise<Result> {
const response = await app.inject({ method: 'POST', url: '/graphql', payload: { query } });
expect(response.statusCode).toBe(200);
return response.json<Result>();
}

it('keeps the runtime and published service roots nullable', async () => {
const published = buildSchema(await readFile('generated-schema.graphql', 'utf8'));
const runtime = app.get(GraphQLSchemaHost).schema;
for (const schema of [published, runtime]) {
for (const root of ['vms', 'docker']) {
expect(schema.getQueryType()!.getFields()[root].type.toString()).toBe(
root === 'vms' ? 'Vms' : 'Docker'
);
}
}
});

it.each(['domain', 'domains'])('preserves other roots when vms.%s fails', async (field) => {
vms.getDomains.mockRejectedValue(new Error('VMs are not available'));
const result = await execute(
`{ serverStatus vms { ${field} { id } } docker { networks { id } } }`
);
expect(result.data).toEqual({
serverStatus: 'available',
vms: { [field]: null },
docker: { networks: [] },
});
expect(result.errors?.map((error) => error.path)).toEqual([['vms', field]]);
});

it('preserves other roots when Docker fails', async () => {
docker.getNetworks.mockRejectedValue(new Error('Docker is not available'));
const result = await execute(
'{ serverStatus vms { domains { id } } docker { networks { id } } }'
);
expect(result.data).toEqual({ serverStatus: 'available', vms: { domains: [] }, docker: null });
expect(result.errors?.map((error) => error.path)).toEqual([['docker', 'networks']]);
});

it('retains successful data and both errors when both services fail', async () => {
vms.getDomains.mockRejectedValue(new Error('VMs are not available'));
docker.getNetworks.mockRejectedValue(new Error('Docker is not available'));
const result = await execute(
'{ serverStatus guests: vms { domains { id } } containers: docker { networks { id } } }'
);
expect(result.data).toEqual({
serverStatus: 'available',
guests: { domains: null },
containers: null,
});
expect(result.errors?.map((error) => error.path)).toEqual(
expect.arrayContaining([
['guests', 'domains'],
['containers', 'networks'],
])
);
expect(result.errors).toHaveLength(2);
});

it('preserves successful empty service results', async () => {
const result = await execute(
'{ serverStatus vms { domains { id } } docker { networks { id } } }'
);
expect(result.errors).toBeUndefined();
expect(result.data).toEqual({
serverStatus: 'available',
vms: { domains: [] },
docker: { networks: [] },
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class DockerResolver {
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@Query(() => Docker)
@Query(() => Docker, { nullable: true })
public docker() {
return {
id: 'docker',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { ConfigService } from '@nestjs/config';
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

import type { FSWatcher } from 'chokidar';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { NotificationType } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';

describe.sequential('Notification overview visibility', () => {
let basePath: string;
let service: NotificationsService;
const file = (type: NotificationType, id: string) => join(basePath, type.toLowerCase(), id);

async function write(type: NotificationType, id: string, importance = 'normal') {
await writeFile(
file(type, id),
`timestamp=1700000000\nevent=Test\nsubject=Test\ndescription=Test\nimportance=${importance}\n`
);
}

async function start(keepWatcher = false) {
service = new NotificationsService(
new ConfigService({ store: { dynamix: { notify: { path: basePath } } } })
);
await Reflect.get(service, 'initialization');
if (!keepWatcher) await stopWatcher();
}

async function stopWatcher() {
const watcher: FSWatcher | null = Reflect.get(NotificationsService, 'watcher');
await watcher?.close();
Reflect.set(NotificationsService, 'watcher', null);
}

async function add(type: NotificationType, id: string) {
await Reflect.get(service, 'handleNotificationAdd').call(service, file(type, id));
}

async function expectCounts(unread: number, archive: number) {
const overview = service.getOverview();
expect(overview.unread.total).toBe(unread);
expect(overview.archive.total).toBe(archive);
for (const type of Object.values(NotificationType)) {
const notifications = await service.getNotifications({ type, offset: 0, limit: 100 });
expect(overview[type.toLowerCase()].total).toBe(notifications.length);
}
}

beforeEach(async () => {
basePath = await mkdtemp(join(tmpdir(), 'notification-overview-'));
await Promise.all(
Object.values(NotificationType).map((type) => mkdir(join(basePath, type.toLowerCase())))
);
});

afterEach(async () => {
await stopWatcher();
await Reflect.get(service, 'overviewRefresh');
await rm(basePath, { recursive: true, force: true });
});

it('hydrates counts with the same duplicate filtering as the lists', async () => {
for (const importance of ['normal', 'warning', 'alert']) {
await write(NotificationType.UNREAD, `${importance}.notify`, importance);
await write(NotificationType.ARCHIVE, `${importance}.notify`, importance);
await write(NotificationType.ARCHIVE, `archived-${importance}.notify`, importance);
}
await start();
await expectCounts(3, 3);
expect(service.getOverview().archive).toEqual({ info: 1, warning: 1, alert: 1, total: 3 });
await service.recalculateOverview();
await expectCounts(3, 3);
});

it.each([NotificationType.UNREAD, NotificationType.ARCHIVE])(
'handles legacy copies written %s first and repeated events',
async (first) => {
await start();
const second =
first === NotificationType.UNREAD ? NotificationType.ARCHIVE : NotificationType.UNREAD;
await write(first, 'legacy.notify');
await add(first, 'legacy.notify');
await write(second, 'legacy.notify');
await add(second, 'legacy.notify');
await add(first, 'legacy.notify');
await expectCounts(1, 0);
}
);

it('archives over an existing legacy archive copy and restores it', async () => {
await write(NotificationType.UNREAD, 'legacy.notify');
await write(NotificationType.ARCHIVE, 'legacy.notify');
await start();
await service.archiveNotification({ id: 'legacy.notify' });
await expectCounts(0, 1);
await service.markAsUnread({ id: 'legacy.notify' });
await expectCounts(1, 0);
});

it('reveals the archive copy when unread is deleted', async () => {
await write(NotificationType.UNREAD, 'legacy.notify');
await write(NotificationType.ARCHIVE, 'legacy.notify');
await start();
await service.deleteNotification({ id: 'legacy.notify', type: NotificationType.UNREAD });
await expectCounts(0, 1);
});

it('does not decrement the archive count when a hidden copy is deleted', async () => {
await write(NotificationType.UNREAD, 'legacy.notify');
await write(NotificationType.ARCHIVE, 'legacy.notify');
await start();
await service.deleteNotification({ id: 'legacy.notify', type: NotificationType.ARCHIVE });
await expectCounts(1, 0);
});

it('recounts both categories after bulk deletes', async () => {
await write(NotificationType.UNREAD, 'legacy.notify');
await write(NotificationType.ARCHIVE, 'legacy.notify');
await start();
await service.deleteNotifications(NotificationType.UNREAD);
await expectCounts(0, 1);
await service.deleteAllNotifications();
await expectCounts(0, 0);
});

it('serializes snapshots and coalesces requests queued during a refresh', async () => {
await start();
const snapshot = Reflect.get(service, 'buildOverviewSnapshot').bind(service);
const captured = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
let calls = 0;
Reflect.set(service, 'buildOverviewSnapshot', async () => {
calls++;
const result = await snapshot();
if (calls === 1) {
captured.resolve();
await release.promise;
}
return result;
});
const first = service.recalculateOverview();
await captured.promise;
await write(NotificationType.UNREAD, 'during-refresh.notify');
const queued = Array.from({ length: 20 }, () => service.recalculateOverview());
release.resolve();
await Promise.all([first, ...queued]);
expect(calls).toBe(2);
await expectCounts(1, 0);
});

it('can refresh again after a snapshot failure', async () => {
await start();
const snapshot = Reflect.get(service, 'buildOverviewSnapshot');
Reflect.set(service, 'buildOverviewSnapshot', () => Promise.reject(new Error('read failure')));
await expect(service.recalculateOverview()).rejects.toThrow();
Reflect.set(service, 'buildOverviewSnapshot', snapshot);
await write(NotificationType.UNREAD, 'after-failure.notify');
await service.recalculateOverview();
await expectCounts(1, 0);
});

it('handles new events while draining adds queued during hydration', async () => {
await start();
Reflect.set(service, 'getNotificationsWatcher', async () => null);
const snapshot = Reflect.get(service, 'buildOverviewSnapshot').bind(service);
const processAdd = Reflect.get(service, 'processNotificationAdd').bind(service);
let firstSnapshot = true;
Reflect.set(service, 'buildOverviewSnapshot', async () => {
const result = await snapshot();
if (firstSnapshot) {
firstSnapshot = false;
await write(NotificationType.UNREAD, 'queued.notify');
await add(NotificationType.UNREAD, 'queued.notify');
}
return result;
});
Reflect.set(service, 'processNotificationAdd', async (path: string) => {
await processAdd(path);
if (path.endsWith('/queued.notify')) {
await write(NotificationType.UNREAD, 'late.notify');
await add(NotificationType.UNREAD, 'late.notify');
}
});
await Reflect.get(service, 'initializeNotificationsState').call(service, basePath);
await expectCounts(2, 0);
});

it('tracks external writes and unlink events through the real watcher', async () => {
await start(true);
const watcher: FSWatcher = Reflect.get(NotificationsService, 'watcher');
await new Promise<void>((resolve) => watcher.once('ready', resolve));
await write(NotificationType.ARCHIVE, 'external.notify');
await vi.waitFor(() => expect(service.getOverview().archive.total).toBe(1));
await write(NotificationType.UNREAD, 'external.notify');
await vi.waitFor(() =>
expect(service.getOverview()).toMatchObject({ unread: { total: 1 }, archive: { total: 0 } })
);
await unlink(file(NotificationType.UNREAD, 'external.notify'));
await vi.waitFor(() =>
expect(service.getOverview()).toMatchObject({ unread: { total: 0 }, archive: { total: 1 } })
);
await expectCounts(0, 1);
});
});
Loading
Loading