diff --git a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.spec.ts b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.spec.ts new file mode 100644 index 0000000000..b4a2a27756 --- /dev/null +++ b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.spec.ts @@ -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 = {}): Record => ({ + 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' })], + }, + ]); + }); + }); +}); diff --git a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.ts b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.ts index baaf44f6fe..6fcf5c0c82 100644 --- a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.ts +++ b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-api.service.ts @@ -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 || [], diff --git a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.spec.ts b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.spec.ts new file mode 100644 index 0000000000..738d3d61fc --- /dev/null +++ b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.spec.ts @@ -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; + let issueProviderService: jasmine.SpyObj; + + 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'); + }); + }); +}); diff --git a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.ts b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.ts index 9fc9351c8d..6dd7054316 100644 --- a/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.ts +++ b/src/app/features/issue/providers/nextcloud-deck/nextcloud-deck-common-interfaces.service.ts @@ -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,