mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
59c4b75d7b
commit
9b35c30f9c
4 changed files with 232 additions and 3 deletions
|
|
@ -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' })],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 || [],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue