fix(nextcloud-deck): normalize card done to boolean to prevent isDone: null

The Deck REST API returns a card's done field as a nullable completion
timestamp, not a boolean (proven by the write path that sends an ISO
string). The raw value was copied straight into task.isDone, so not-done
cards stored isDone: null, failing typia validation and triggering the
data-repair dialog on every startup/poll. In the batch poll path done
cards are filtered out, so every synced card had done: null.

Coerce to a real boolean at the API mapping boundary and correct the
DeckCardResponse.done type to string | null to reflect the API.

Fixes #8436
This commit is contained in:
Johannes Millan 2026-06-17 13:29:00 +02:00
parent 59c4b75d7b
commit 9b35c30f9c
4 changed files with 232 additions and 3 deletions

View file

@ -0,0 +1,113 @@
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { NextcloudDeckApiService } from './nextcloud-deck-api.service';
import { SnackService } from '../../../../core/snack/snack.service';
import { NextcloudDeckCfg } from './nextcloud-deck.model';
describe('NextcloudDeckApiService', () => {
let service: NextcloudDeckApiService;
let httpMock: HttpTestingController;
const mockCfg: NextcloudDeckCfg = {
isEnabled: true,
nextcloudBaseUrl: 'https://nc.example.com',
username: 'user',
password: 'pass',
selectedBoardId: 1,
selectedBoardTitle: 'Board',
importStackIds: null,
doneStackId: null,
isTransitionIssuesEnabled: false,
filterByAssignee: false,
titleTemplate: null,
pollIntervalMinutes: 5,
};
const stacksUrl = 'https://nc.example.com/index.php/apps/deck/api/v1.0/boards/1/stacks';
// A raw card as returned by the Deck REST API. `done` is a completion
// timestamp (or null) — NOT a boolean (see issue #8436).
const rawCard = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id: 42,
title: 'A card',
description: '',
duedate: null,
lastModified: 123,
archived: false,
done: null,
order: 0,
labels: [],
assignedUsers: [],
...overrides,
});
beforeEach(() => {
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
NextcloudDeckApiService,
{ provide: SnackService, useValue: snackServiceSpy },
],
});
service = TestBed.inject(NextcloudDeckApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('done normalization (issue #8436)', () => {
it('getOpenCards$ maps a not-done card (done: null) to boolean false', (done) => {
service.getOpenCards$(mockCfg).subscribe((cards) => {
expect(cards.length).toBe(1);
expect(cards[0].done).toBe(false);
expect(typeof cards[0].done).toBe('boolean');
done();
});
httpMock
.expectOne(stacksUrl)
.flush([{ id: 1, title: 'Stack', boardId: 1, cards: [rawCard()] }]);
});
it('getById$ maps done: null to boolean false', (done) => {
service.getById$(42, mockCfg).subscribe((issue) => {
expect(issue).not.toBeNull();
expect(issue!.done).toBe(false);
expect(typeof issue!.done).toBe('boolean');
done();
});
httpMock
.expectOne(stacksUrl)
.flush([{ id: 1, title: 'Stack', boardId: 1, cards: [rawCard()] }]);
});
it('getById$ maps a completion timestamp to boolean true', (done) => {
service.getById$(42, mockCfg).subscribe((issue) => {
expect(issue).not.toBeNull();
expect(issue!.done).toBe(true);
expect(typeof issue!.done).toBe('boolean');
done();
});
httpMock.expectOne(stacksUrl).flush([
{
id: 1,
title: 'Stack',
boardId: 1,
cards: [rawCard({ done: '2024-01-01T00:00:00+00:00' })],
},
]);
});
});
});

View file

@ -29,7 +29,8 @@ interface DeckCardResponse {
duedate: string | null;
lastModified: number;
archived: boolean;
done: boolean;
// Deck API returns a completion timestamp (or null), not a boolean
done: string | null;
order: number;
labels: DeckLabel[];
assignedUsers: DeckAssignedUser[];
@ -218,7 +219,7 @@ export class NextcloudDeckApiService {
stackId: stack.id,
stackTitle: stack.title,
lastModified: card.lastModified,
done: card.done,
done: !!card.done,
labels: card.labels || [],
});
}
@ -238,7 +239,7 @@ export class NextcloudDeckApiService {
description: card.description || '',
duedate: card.duedate,
lastModified: card.lastModified,
done: card.done,
done: !!card.done,
order: card.order,
labels: card.labels || [],
assignedUsers: card.assignedUsers || [],

View file

@ -0,0 +1,113 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { NextcloudDeckCommonInterfacesService } from './nextcloud-deck-common-interfaces.service';
import { NextcloudDeckApiService } from './nextcloud-deck-api.service';
import { IssueProviderService } from '../../issue-provider.service';
import { DEFAULT_NEXTCLOUD_DECK_CFG } from './nextcloud-deck.const';
import {
NextcloudDeckIssue,
NextcloudDeckIssueReduced,
} from './nextcloud-deck-issue.model';
import { createTask } from '../../../tasks/task.test-helper';
import { Task } from '../../../tasks/task.model';
import { IssueProviderNextcloudDeck } from '../../issue.model';
const ISSUE_PROVIDER_ID = 'deck-provider-1';
const ISSUE_ID = '42';
const CFG: IssueProviderNextcloudDeck = {
...DEFAULT_NEXTCLOUD_DECK_CFG,
id: ISSUE_PROVIDER_ID,
issueProviderKey: 'NEXTCLOUD_DECK',
isEnabled: true,
nextcloudBaseUrl: 'https://nc.example.com',
username: 'user',
password: 'pass',
selectedBoardId: 1,
};
const makeReducedCard = (done: boolean): NextcloudDeckIssueReduced => ({
id: 42,
title: 'A card',
stackId: 1,
stackTitle: 'Stack',
lastModified: 200,
done,
labels: [],
});
const makeIssue = (done: boolean): NextcloudDeckIssue => ({
...makeReducedCard(done),
description: '',
duedate: null,
assignedUsers: [],
boardId: 1,
order: 0,
});
const makeTask = (): Task =>
createTask({
id: 'task-1',
title: 'A card',
issueId: ISSUE_ID,
issueProviderId: ISSUE_PROVIDER_ID,
issueType: 'NEXTCLOUD_DECK',
issueLastUpdated: 100,
});
describe('NextcloudDeckCommonInterfacesService', () => {
let service: NextcloudDeckCommonInterfacesService;
let apiService: jasmine.SpyObj<NextcloudDeckApiService>;
let issueProviderService: jasmine.SpyObj<IssueProviderService>;
beforeEach(() => {
apiService = jasmine.createSpyObj('NextcloudDeckApiService', [
'getById$',
'getOpenCards$',
]);
issueProviderService = jasmine.createSpyObj('IssueProviderService', ['getCfgOnce$']);
issueProviderService.getCfgOnce$.and.returnValue(of(CFG));
TestBed.configureTestingModule({
providers: [
NextcloudDeckCommonInterfacesService,
{ provide: NextcloudDeckApiService, useValue: apiService },
{ provide: IssueProviderService, useValue: issueProviderService },
],
});
service = TestBed.inject(NextcloudDeckCommonInterfacesService);
});
// Guards the sinks that write to synced task.isDone (issue #8436). The API
// service normalizes done to a boolean; these assert the value is forwarded
// as a real boolean and never null/undefined.
describe('isDone is always a boolean (issue #8436)', () => {
it('getFreshDataForIssueTask forwards done:false as boolean false', async () => {
apiService.getById$.and.returnValue(of(makeIssue(false)));
const result = await service.getFreshDataForIssueTask(makeTask());
expect(result?.taskChanges.isDone).toBe(false);
expect(typeof result?.taskChanges.isDone).toBe('boolean');
});
it('getFreshDataForIssueTask forwards done:true as boolean true', async () => {
apiService.getById$.and.returnValue(of(makeIssue(true)));
const result = await service.getFreshDataForIssueTask(makeTask());
expect(result?.taskChanges.isDone).toBe(true);
expect(typeof result?.taskChanges.isDone).toBe('boolean');
});
it('getFreshDataForIssueTasks (batch poll) forwards done as boolean false', async () => {
apiService.getOpenCards$.and.returnValue(of([makeReducedCard(false)]));
const result = await service.getFreshDataForIssueTasks([makeTask()]);
expect(result.length).toBe(1);
expect(result[0].taskChanges.isDone).toBe(false);
expect(typeof result[0].taskChanges.isDone).toBe('boolean');
});
});
});

View file

@ -98,6 +98,7 @@ export class NextcloudDeckCommonInterfacesService extends BaseIssueProviderServi
taskChanges: {
...this.getAddTaskData(issue, cfg),
issueWasUpdated: true,
// normalized to a boolean in NextcloudDeckApiService mapping (issue #8436)
isDone: issue.done,
},
issue,
@ -138,6 +139,7 @@ export class NextcloudDeckCommonInterfacesService extends BaseIssueProviderServi
taskChanges: {
...this.getAddTaskData(card, cfg),
issueWasUpdated: true,
// normalized to a boolean in NextcloudDeckApiService mapping (issue #8436)
isDone: card.done,
},
issue: card as NextcloudDeckIssue,