diff --git a/src/main/java/com/target/devicemanager/common/DeviceListener.java b/src/main/java/com/target/devicemanager/common/DeviceListener.java index 706ecc5..90c7355 100644 --- a/src/main/java/com/target/devicemanager/common/DeviceListener.java +++ b/src/main/java/com/target/devicemanager/common/DeviceListener.java @@ -15,12 +15,21 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + public class DeviceListener implements DataListener, ErrorListener, StatusUpdateListener, OutputCompleteListener { private final EventSynchronizer eventSynchronizer; private static final Logger LOGGER = LoggerFactory.getLogger(DeviceListener.class); private static final StructuredEventLogger log = StructuredEventLogger.of(StructuredEventLogger.getCommonServiceName(), "DeviceListener", LOGGER); + private static final long ERROR_LOG_DUP_MS = 1000; + + private final AtomicReference lastErrorKey = new AtomicReference<>(""); + private final AtomicLong lastErrorLogTime = new AtomicLong(0); + public DeviceListener(EventSynchronizer eventSynchronizer) { if (eventSynchronizer == null) { throw new IllegalArgumentException("eventSynchronizer cannot be null"); @@ -36,11 +45,33 @@ public void dataOccurred(DataEvent dataEvent) { @Override public void errorOccurred(ErrorEvent errorEvent) { - log.failure("errorOccurred(): errCode=" + errorEvent.getErrorCode() - + " errCodeExt=" + errorEvent.getErrorCodeExtended() - + " errLocus=" + errorEvent.getErrorLocus() - + " errResponse=" + errorEvent.getErrorResponse(), 17, null); int errorCode = errorEvent.getErrorCode(); + int errorExt = errorEvent.getErrorCodeExtended(); + int errorLocus = errorEvent.getErrorLocus(); + int errorResponse = errorEvent.getErrorResponse(); + + String currentKey = errorCode + ":" + errorExt + ":" + errorLocus + ":" + errorResponse; + long now = System.currentTimeMillis(); + + String previousKey = lastErrorKey.get(); + long previousTime = lastErrorLogTime.get(); + + boolean shouldLog = !currentKey.equals(previousKey) || (now - previousTime) > ERROR_LOG_DUP_MS; + + if (shouldLog) { + lastErrorKey.set(currentKey); + lastErrorLogTime.set(now); + + log.failure( + "errorOccurred(): errCode=" + errorCode + + " errCodeExt=" + errorExt + + " errLocus=" + errorLocus + + " errResponse=" + errorResponse, + 17, + null + ); + } + if (errorCode == JposConst.JPOS_E_OFFLINE || errorCode == JposConst.JPOS_E_NOHARDWARE) { BaseService jposService = (BaseService) errorEvent.getSource(); try { @@ -49,6 +80,11 @@ public void errorOccurred(ErrorEvent errorEvent) { log.failure("close failed", 17, jposException); } } + + if (errorLocus == JposConst.JPOS_EL_OUTPUT) { + errorEvent.setErrorResponse(JposConst.JPOS_ER_CLEAR); + } + eventSynchronizer.triggerEvent(errorEvent); } @@ -113,6 +149,22 @@ public void waitForOutputToComplete() throws JposException { log.success("waitForOutputToComplete(out)", 1); } + // This is waitForOutputToComplete with a timeout, which allows the caller to recover and release locks if the device is not responding. Currently only used by printer + public void waitForOutputToComplete(long timeout, TimeUnit unit) throws JposException { + log.success("waitForOutputToComplete(timeout=" + unit.toMillis(timeout) + "ms, in)", 1); + JposEvent jposEvent = eventSynchronizer.waitForEvent(timeout, unit); + if (jposEvent instanceof ErrorEvent) { + throw jposExceptionFromErrorEvent((ErrorEvent) jposEvent); + } + if (jposEvent instanceof StatusUpdateEvent) { + throw jposExceptionFromStatusUpdateEvent((StatusUpdateEvent) jposEvent); + } + if (!(jposEvent instanceof OutputCompleteEvent)) { + throw new JposException(JposConst.JPOS_E_FAILURE); + } + log.success("waitForOutputToComplete(timeout, out)", 1); + } + public StatusUpdateEvent waitForStatusUpdate() throws JposException { JposEvent jposEvent = eventSynchronizer.waitForEvent(); if (jposEvent instanceof ErrorEvent) { diff --git a/src/main/java/com/target/devicemanager/common/EventSynchronizer.java b/src/main/java/com/target/devicemanager/common/EventSynchronizer.java index b4a48a9..62497e0 100644 --- a/src/main/java/com/target/devicemanager/common/EventSynchronizer.java +++ b/src/main/java/com/target/devicemanager/common/EventSynchronizer.java @@ -1,12 +1,15 @@ package com.target.devicemanager.common; import jpos.JposConst; +import jpos.JposException; import jpos.events.ErrorEvent; import jpos.events.JposEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.Phaser; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -62,6 +65,32 @@ public JposEvent waitForEvent() { return tmpEvent; } + // Waits for an event up to the specified timeout. + public JposEvent waitForEvent(long timeout, TimeUnit unit) throws JposException { + try { + phaser.awaitAdvanceInterruptibly(waitingPhase.get(), timeout, unit); + } catch (TimeoutException timeoutException) { + synchronized (this) { + areEventsActive.set(false); + } + log.failure("waitForEvent timed out after " + unit.toMillis(timeout) + "ms", 18, null); + throw new JposException(JposConst.JPOS_E_TIMEOUT); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + synchronized (this) { + areEventsActive.set(false); + } + log.failure("waitForEvent interrupted", 17, interruptedException); + throw new JposException(JposConst.JPOS_E_TIMEOUT); + } + JposEvent tmpEvent; + synchronized (this) { + areEventsActive.set(false); + tmpEvent = lastEvent; + } + return tmpEvent; + } + public synchronized void stopWaitingForEvent() { this.lastEvent = new ErrorEvent(this, JposConst.JPOS_E_TIMEOUT, 0, 0, 0); phaser.arrive(); diff --git a/src/main/java/com/target/devicemanager/components/printer/PrinterDevice.java b/src/main/java/com/target/devicemanager/components/printer/PrinterDevice.java index 43bbec0..46a8179 100644 --- a/src/main/java/com/target/devicemanager/components/printer/PrinterDevice.java +++ b/src/main/java/com/target/devicemanager/components/printer/PrinterDevice.java @@ -16,6 +16,7 @@ import java.util.Base64; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; public class PrinterDevice implements StatusUpdateListener { @@ -31,8 +32,11 @@ public class PrinterDevice implements StatusUpdateListener { private boolean isReconnectNeeded = false; private static final String R5PrinterName = "NCR Kiosk POS Printer"; private static final int TRY_LOCK_TIMEOUT = 1; + private static final int PRINT_TIMEOUT_SECONDS = 30; private final ReentrantLock connectLock; - private boolean isLocked = false; + private volatile boolean isLocked = false; + private final AtomicReference lockOwnerThread = new AtomicReference<>(null); + private volatile boolean interruptedByTimeout = false; private final int[] ref = new int[1]; private static final Logger LOGGER = LoggerFactory.getLogger(PrinterDevice.class); private static final StructuredEventLogger log = StructuredEventLogger.of(StructuredEventLogger.getPrinterServiceName(), "PrinterDevice", LOGGER); @@ -148,92 +152,190 @@ private void enable() throws JposException { log.failure("Printer Failed to Enable Device: " + jposException.getErrorCode() + ", " + jposException.getErrorCodeExtended(), 18, jposException); throw jposException; } - deviceListener.startEventListeners(); } /** * Prints the content on the receipt. - * @param contents the image on receipt. + * + * @param contents the image on receipt. * @param printerStation register where printing occurs. * @throws JposException, PrinterException */ - public Void printContent(List contents, int printerStation) throws JposException, PrinterException { - if (tryLock()) { - POSPrinter printer; + + public void printContent(List contents, int printerStation) + throws JposException, PrinterException { + log.success("printContent() invoked", 5); + + if (!tryLock()) { + log.success("Printer lock unavailable", 5); + throw new PrinterException(PrinterError.PRINTER_BUSY); + } + + POSPrinter printer = null; + boolean transactionStarted = false; + + try { synchronized (printer = dynamicPrinter.getDevice()) { - try { - if (contents == null || contents.isEmpty()) { - PrinterException printerException = new PrinterException(PrinterError.INVALID_FORMAT); - log.failure("Receipt contents are empty", 5, printerException); - throw printerException; - } - enable(); - if (printerStation != PrinterStationType.CHECK_PRINTER.getValue() && (wasPaperEmpty || paperEmptyCheck())) { - // Throw JPOS extended error JPOS_EPTR_REC_EMPTY - throw new JposException(114, 203); + + log.success("Acquired printer and entered synchronized block", 5); + + if (interruptedByTimeout) { + log.failure("Aborting printContent — interrupted by forceUnlock timeout before print started", 17, null); + throw new JposException(JposConst.JPOS_E_TIMEOUT); + } + // Clear status + PrinterErrorHandlingSingleton.getPrinterErrorHandlingSingleton().clearError(); + log.success("Cleared singleton error state", 5); + + if (contents == null || contents.isEmpty()) { + log.failure("Receipt contents are empty", 17, null); + throw new PrinterException(PrinterError.INVALID_FORMAT); + } + + enable(); + log.success("Printer enabled successfully", 5); + + if (printerStation != PrinterStationType.CHECK_PRINTER.getValue() + && (wasPaperEmpty || paperEmptyCheck())) { + log.failure("Paper empty detected before print", 13, null); + throw new JposException(114, 203); + } + + reconnectR5Printer(); + log.success("Reconnect Check for R5 completed", 5); + printer.transactionPrint(printerStation, POSPrinterConst.PTR_TP_TRANSACTION); + transactionStarted = true; + log.success("Transaction started", 5); + + int index = 0; + for (PrinterContent content : contents) { + index++; + + if (content == null || content.type == null) { + log.failure("Invalid content at index " + index, 13, null); + throw new PrinterException(PrinterError.INVALID_FORMAT); } - reconnectR5Printer(); - printer.transactionPrint(printerStation, POSPrinterConst.PTR_TP_TRANSACTION); - for (PrinterContent content : contents) { - switch (content.type.toString()) { - case "BARCODE": - print(printer, (BarcodeContent) content, printerStation); - break; - case "IMAGE": - print(printer, (ImageContent) content, printerStation); - break; - case "TEXT": - default: - print(printer, content.data, printerStation); - break; - } + + switch (content.type.toString()) { + case "BARCODE": + print(printer, (BarcodeContent) content, printerStation); + break; + case "IMAGE": + print(printer, (ImageContent) content, printerStation); + break; + case "TEXT": + default: + print(printer, content.data, printerStation); + break; } + } + + log.success("All content sent to printer buffer", 5); + + deviceListener.startEventListeners(); + log.success("Event synchronizer re-armed before wait", 5); + + printer.transactionPrint(printerStation, POSPrinterConst.PTR_TP_NORMAL); + transactionStarted = false; + log.success("Transaction ended (PTR_TP_NORMAL)", 5); + + deviceListener.waitForOutputToComplete(PRINT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + log.success("Output complete event received", 5); + + PrinterException statusError = + PrinterErrorHandlingSingleton.getPrinterErrorHandlingSingleton().getError(); + + if (statusError != null) { + log.failure("Singleton error detected after print: " + + statusError.getDeviceError().getDescription(), 17, statusError); + throw statusError; + } + + log.success("printContent() completed successfully", 5); + } + + } catch (PrinterException printerException) { + log.failure("PrinterException: " + printerException.getDeviceError().getDescription(), 18, printerException); + throw printerException; + + } catch (JposException jposException) { + log.failure("JposException: " + jposException.getErrorCode() + + ", " + jposException.getErrorCodeExtended(), 18, jposException); + + boolean printTimeoutError = jposException.getErrorCode() == JposConst.JPOS_E_TIMEOUT; + + if (printTimeoutError) { + if (interruptedByTimeout) { + log.failure("Print timeout after forceUnlock interrupt — skipping disconnect, recovery owned by forceUnlock", 17, jposException); + interruptedByTimeout = false; + } else { + log.failure("Print timed out after " + PRINT_TIMEOUT_SECONDS + + "s waiting for OutputComplete event. Disconnecting and reconnecting to recover.", 18, jposException); + disconnect(); + connect(); + } + throw jposException; + } + + boolean failureOrDisabledError = jposException.getErrorCode() == 111 + || jposException.getErrorCode() == 105; + + boolean paperEmptyError = jposException.getErrorCode() == 114 + && jposException.getErrorCodeExtended() == 203; + + boolean badPrintContentError = jposException.getErrorCode() == 106 + || (jposException.getErrorCode() == 114 && jposException.getErrorCodeExtended() == 207); + + if (badPrintContentError) { + log.failure("Detected invalid print content error", 13, jposException); + throw new PrinterException(PrinterError.INVALID_FORMAT); + } + + if (failureOrDisabledError) { + log.failure("Device failure/disabled detected.", 18, jposException); + } + + if (paperEmptyError) { + log.failure("Paper empty detected", 18, jposException); + } + + throw jposException; + + } finally { + log.success("Entering finally block", 5); + + try { + if (printer != null && transactionStarted) { + log.failure("Closing open transaction in finally", 17,null); printer.transactionPrint(printerStation, POSPrinterConst.PTR_TP_NORMAL); - deviceListener.waitForOutputToComplete(); - - } catch (JposException jposException) { - log.failure("Printer Failed to Print Content: " + jposException.getErrorCode() + ", " + jposException.getErrorCodeExtended(), 18, jposException); - - boolean failureOrDisabledError = jposException.getErrorCode() == 111 || jposException.getErrorCode() == 105; - boolean badPrintContentError = jposException.getErrorCode() == 106 || (jposException.getErrorCode() == 114 && jposException.getErrorCodeExtended() == 207); - if ((failureOrDisabledError || badPrintContentError)) { - log.failure("Received Printer " + jposException.getErrorCode() + " error. Disconnecting device.", 18, jposException); - disconnect(); - log.failure("Received Printer " + jposException.getErrorCode() + " error. Reconnecting device.", 18, jposException); - connect(); - if (badPrintContentError) { - throw new PrinterException(PrinterError.INVALID_FORMAT); - } - } - throw jposException; - } finally { - JposException jposException = null; - try { - printer.clearOutput(); - } catch (JposException exception) { - log.failure("Received printer " + exception.getErrorCode() + " error during clearOutput()", 17, exception); - jposException = exception; - } - // if an exception is thrown during print, make sure check is spit out. - if (getIsCheckInserted()) { - try { - withdrawCheck(); - } catch (JposException exception) { - log.failure("Received printer " + exception.getErrorCode() + " error during withdrawCheck()", 17, exception); - if (jposException == null) { - jposException = exception; - } - } - } - unlock(); - if (jposException != null) { - throw jposException; - } } + } catch (JposException cleanupException) { + log.failure("Failed to end transaction: " + + cleanupException.getErrorCode() + ", " + + cleanupException.getErrorCodeExtended(), 17, cleanupException); } - return null; - } else { - throw new PrinterException(PrinterError.PRINTER_BUSY); + + try { + if (printer != null) { + printer.clearOutput(); + log.success("Cleared printer output buffer", 5); + } + } catch (JposException cleanupException) { + log.failure("clearOutput failed: " + + cleanupException.getErrorCode(), 17, cleanupException); + } + + if (getIsCheckInserted()) { + try { + withdrawCheck(); + log.success("Withdraw check executed", 5); + } catch (JposException cleanupException) { + log.failure("withdrawCheck failed: " + + cleanupException.getErrorCode(), 17, cleanupException); + } + } + unlock(); + log.success("Printer lock released", 5); } } @@ -358,14 +460,26 @@ public void setWasPaperEmpty(boolean paperEmpty) { * @throws JposException */ private void reconnectR5Printer() throws JposException { - POSPrinter printer; - synchronized (printer = dynamicPrinter.getDevice()) { + boolean acquiredLock = false; + if (!connectLock.isHeldByCurrentThread()) { + if (!tryLock()) { + log.success("reconnectR5Printer: lock unavailable, skipping reconnect", 5); + return; + } + acquiredLock = true; + } + try { + POSPrinter printer = dynamicPrinter.getDevice(); if (printer.getPhysicalDeviceName().contains(R5PrinterName) && getIsReconnectNeeded()) { log.success("Reconnecting R5 printer", 9); disconnect(); connect(); setIsReconnectNeeded(false); } + } finally { + if (acquiredLock) { + unlock(); + } } } @@ -434,6 +548,7 @@ public void statusUpdateOccurred(StatusUpdateEvent statusUpdateEvent) { log.success("Status Update: Printer cover is open", 13); setWasDoorOpened(true); setIsReconnectNeeded(false); + deviceListener.statusUpdateOccurred(statusUpdateEvent); break; case POSPrinterConst.PTR_SUE_COVER_OK: log.success("Status Update: Printer cover OK", 5); @@ -463,11 +578,11 @@ public void statusUpdateOccurred(StatusUpdateEvent statusUpdateEvent) { break; case POSPrinterConst.PTR_SUE_REC_PAPEROK: log.success("Status Update: Receipt paper OK", 5); + clearPrinterBuffer(); if (printerErrorHandlingSingleton.getError() != null) { printerErrorHandlingSingleton.clearError(); } if (getWasPaperEmpty()) { - clearPrinterBuffer(); setIsReconnectNeeded(true); setWasPaperEmpty(false); } @@ -517,6 +632,9 @@ public boolean tryLock() { try { isLocked = connectLock.tryLock(TRY_LOCK_TIMEOUT, TimeUnit.SECONDS); log.success("Lock: " + isLocked, 1); + if (isLocked) { + lockOwnerThread.set(Thread.currentThread()); + } } catch (InterruptedException interruptedException) { log.failure("Lock Failed: " + interruptedException.getMessage(), 17, interruptedException); } @@ -527,8 +645,74 @@ public boolean tryLock() { * unlock the current resource. */ public void unlock() { + isLocked = false; connectLock.unlock(); + lockOwnerThread.set(null); + } + + public void forceUnlock() { + log.failure("forceUnlock() called — interrupting stuck worker thread and disconnecting device", 17, null); + Thread owner = lockOwnerThread.get(); + + if (owner == null) { + log.failure("forceUnlock: worker already finished — no action needed", 5, null); + deviceConnected = false; + areListenersAttached = false; + return; + } + + if (owner.isAlive()) { + interruptedByTimeout = true; + owner.interrupt(); + } + Thread disconnectThread = new Thread(() -> { + if (lockOwnerThread.get() != owner) { + log.failure("forceUnlock: skipping disconnect — lock owner changed, no longer stuck", 5, null); + return; + } + try { + dynamicPrinter.disconnect(); + log.failure("forceUnlock: background disconnect completed", 17, null); + } catch (Exception ex) { + log.failure("forceUnlock: dynamicPrinter.disconnect() threw: " + ex.getMessage(), 17, null); + } + + boolean reconnected = false; + for (int attempt = 0; attempt < 3; attempt++) { + if (tryLock()) { + try { + log.failure("forceUnlock: reconnecting printer (attempt " + attempt + ")", 17, null); + boolean success = connect(); + if (success) { + log.failure("forceUnlock: printer reconnected successfully", 5, null); + reconnected = true; + } else { + log.failure("forceUnlock: reconnect attempt " + attempt + " failed", 17, null); + } + } finally { + unlock(); + } + break; + } + try { + Thread.sleep(1000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.failure("forceUnlock: reconnect polling interrupted", 17, null); + break; + } + } + if (!reconnected) { + log.failure("forceUnlock: printer did not reconnect — @Scheduled connect() will retry", 17, null); + } + interruptedByTimeout = false; + }, "printer-force-disconnect"); + disconnectThread.setDaemon(true); + disconnectThread.start(); + isLocked = false; + deviceConnected = false; + areListenersAttached = false; } /** diff --git a/src/main/java/com/target/devicemanager/components/printer/PrinterManager.java b/src/main/java/com/target/devicemanager/components/printer/PrinterManager.java index 60f1206..fc17bda 100644 --- a/src/main/java/com/target/devicemanager/components/printer/PrinterManager.java +++ b/src/main/java/com/target/devicemanager/components/printer/PrinterManager.java @@ -31,7 +31,7 @@ public class PrinterManager { private final PrinterDevice printerDevice; private final Lock printerLock; - private static final int PRINTER_TIMEOUT = 10; // Timeout value for printContent call in seconds + private static final int PRINTER_TIMEOUT = 35; // Timeout value for printContent call in seconds private ConnectEnum connectStatus = ConnectEnum.FIRST_CONNECT; private static final Logger LOGGER = LoggerFactory.getLogger(PrinterManager.class); private static final StructuredEventLogger log = StructuredEventLogger.of(StructuredEventLogger.getPrinterServiceName(), "PrinterManager", LOGGER); @@ -124,6 +124,7 @@ public void printReceipt(List contents) throws DeviceException { } executorService.shutdownNow(); // attempt to stop running tasks immediately log.failure(PrinterError.PRINTER_TIME_OUT.getDescription(), 17, timeoutException); + printerDevice.forceUnlock(); throw new PrinterException(PrinterError.PRINTER_TIME_OUT); } catch (InterruptedException interruptedException) { // preserve interrupt status and try to stop worker diff --git a/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceListenerTest.java b/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceListenerTest.java new file mode 100644 index 0000000..fbe2361 --- /dev/null +++ b/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceListenerTest.java @@ -0,0 +1,67 @@ +package com.target.devicemanager.components.printer; + +import com.target.devicemanager.common.EventSynchronizer; +import jpos.JposConst; +import jpos.JposException; +import jpos.POSPrinterConst; +import jpos.events.OutputCompleteEvent; +import jpos.events.StatusUpdateEvent; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class PrinterDeviceListenerTest { + + @Test + public void waitForOutputToComplete_WhenFailureStatusArrives_ThrowsJposException() throws Exception { + PrinterDeviceListener listener = new PrinterDeviceListener(new EventSynchronizer(new Phaser(1))); + listener.startEventListeners(); + + ExecutorService executorService = Executors.newSingleThreadExecutor(); + try { + Future waitFuture = executorService.submit(() -> { + listener.waitForOutputToComplete(); + return null; + }); + + listener.statusUpdateOccurred(new StatusUpdateEvent(this, POSPrinterConst.PTR_SUE_COVER_OPEN)); + + ExecutionException executionException = assertThrows(ExecutionException.class, + () -> waitFuture.get(1, TimeUnit.SECONDS)); + assertInstanceOf(JposException.class, executionException.getCause()); + JposException jposException = (JposException) executionException.getCause(); + assertEquals(JposConst.JPOS_E_EXTENDED, jposException.getErrorCode()); + assertEquals(POSPrinterConst.PTR_SUE_COVER_OPEN, jposException.getErrorCodeExtended()); + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(1, TimeUnit.SECONDS); + } + } + + @Test + public void waitForOutputToComplete_WhenNonFailureStatusArrives_ContinuesWaitingForOutputComplete() throws Exception { + PrinterDeviceListener listener = new PrinterDeviceListener(new EventSynchronizer(new Phaser(1))); + listener.startEventListeners(); + + ExecutorService executorService = Executors.newSingleThreadExecutor(); + try { + Future waitFuture = executorService.submit(() -> { + listener.waitForOutputToComplete(); + return null; + }); + + listener.statusUpdateOccurred(new StatusUpdateEvent(this, POSPrinterConst.PTR_SUE_REC_PAPEROK)); + + assertThrows(TimeoutException.class, () -> waitFuture.get(200, TimeUnit.MILLISECONDS)); + + listener.outputCompleteOccurred(new OutputCompleteEvent(this, 1)); + assertNull(waitFuture.get(1, TimeUnit.SECONDS)); + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(1, TimeUnit.SECONDS); + } + } +} + diff --git a/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceTest.java b/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceTest.java index 2dde931..9df2d7c 100644 --- a/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceTest.java +++ b/src/test/java/com/target/devicemanager/components/printer/PrinterDeviceTest.java @@ -18,7 +18,12 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import static org.junit.jupiter.api.Assertions.*; @@ -480,8 +485,10 @@ public String toString() { //assert catch (JposException jposException) { + // getDevice: 1st from printContent sync, 2nd from paperEmptyCheck sync verify(mockDynamicPrinter, times(2)).getDevice(); - verify(mockPrinter).getPhysicalDeviceName(); + // getPhysicalDeviceName: 1st from paperEmptyCheck + verify(mockPrinter, times(1)).getPhysicalDeviceName(); verify(mockPrinter).directIO(anyInt(), any(), any()); verify(mockPrinter, never()).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).clearOutput(); @@ -515,6 +522,7 @@ public String toString() { //assert catch (JposException jposException) { + // getDevice: 1st from printContent sync, 2nd from paperEmptyCheck sync verify(mockDynamicPrinter, times(2)).getDevice(); verify(mockPrinter).getPhysicalDeviceName(); verify(mockPrinter, never()).directIO(anyInt(), any(), any()); @@ -531,38 +539,40 @@ public String toString() { @Test public void printContent_WhenReconnectR5Printer_ThrowsException() throws JposException { //arrange + // reconnectR5Printer() is called inline in printContent() after paperEmptyCheck. + // With isReconnectNeeded=true and R5 printer name, disconnect()+connect() are triggered. List contents = new ArrayList<>(); - PrinterContent printerContent = new PrinterContent() { - @Override - public String toString() { - return super.toString(); - } - }; - contents.add(printerContent); + TextContent textContent = new TextContent(); + textContent.setType(ContentType.TEXT); + contents.add(textContent); printerDevice.setDeviceConnected(true); printerDevice.setWasPaperEmpty(false); printerDevice.setIsReconnectNeeded(true); - when(mockPrinter.getPhysicalDeviceName()).thenReturn("NCR Kiosk POS Printer").thenThrow(new JposException(JposConst.JPOS_E_EXTENDED)); + when(mockPrinter.getPhysicalDeviceName()).thenReturn("NCR Kiosk POS Printer"); //act try { printerDevice.printContent(contents, 0); } - //assert + //assert — paperEmptyCheck passes (ref[0] is 0 by default, not -2147482880), + // reconnectR5Printer fires disconnect()+connect(), sets isReconnectNeeded=false, + // then print proceeds normally with no exception catch (JposException jposException) { - verify(mockDynamicPrinter, times(3)).getDevice(); - verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter).directIO(anyInt(), any(), any()); - assertTrue(printerDevice.getIsReconnectNeeded()); - verify(mockPrinter, never()).transactionPrint(anyInt(), anyInt()); - verify(mockPrinter).clearOutput(); - return; + fail("Unexpected JposException: " + jposException.getMessage()); } catch (PrinterException printerException) { - fail("Expected JposException, got PrinterException"); + fail("Unexpected PrinterException: " + printerException.getMessage()); } - fail("Expected Exception, but got none"); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer, + // 4th disconnect sync, 5th connect attachListeners sync, 6th connect main sync + verify(mockDynamicPrinter, times(6)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer + verify(mockPrinter, times(2)).getPhysicalDeviceName(); + verify(mockPrinter).directIO(anyInt(), any(), any()); + assertFalse(printerDevice.getIsReconnectNeeded()); + verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); + verify(mockPrinter).clearOutput(); } @Test @@ -589,11 +599,14 @@ public String toString() { //assert catch (JposException jposException) { + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, never()).directIO(anyInt(), any(), any()); assertTrue(printerDevice.getIsReconnectNeeded()); - verify(mockPrinter, times(1)).transactionPrint(anyInt(), anyInt()); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_TRANSACTION); + verify(mockPrinter, never()).transactionPrint(0, POSPrinterConst.PTR_TP_NORMAL); verify(mockPrinter).clearOutput(); return; } catch (PrinterException printerException) { @@ -648,13 +661,17 @@ public void printContent_WhenContentBarcodeFails() throws JposException { //assert catch (JposException jposException) { + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + // (R5 + isReconnectNeeded=true → disconnect+connect fire) + // 4th disconnect sync, 5th connect attachListeners sync, 6th connect main sync verify(mockDynamicPrinter, times(6)).getDevice(); verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockDynamicPrinter).disconnect(); verify(mockDynamicPrinter).connect(); verify(mockPrinter).directIO(anyInt(), any(), any()); assertFalse(printerDevice.getIsReconnectNeeded()); - verify(mockPrinter, times(1)).transactionPrint(anyInt(), anyInt()); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_TRANSACTION); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_NORMAL); verify(mockPrinter).printBarCode(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); verify(mockPrinter).clearOutput(); return; @@ -706,9 +723,11 @@ public void printContent_WhenContentImageFails() throws JposException { //assert catch (JposException jposException) { + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter, times(1)).transactionPrint(anyInt(), anyInt()); + // transaction is started, then closed in finally + verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printMemoryBitmap(anyInt(), any(), anyInt(), anyInt(), anyInt()); verify(mockPrinter).clearOutput(); return; @@ -758,9 +777,11 @@ public void printContent_WhenContentTextFails() throws JposException { //assert catch (JposException jposException) { + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter, times(1)).transactionPrint(anyInt(), anyInt()); + // transaction is started, then closed in finally + verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printNormal(anyInt(), any()); verify(mockPrinter).clearOutput(); return; @@ -789,7 +810,9 @@ public void printContent_WhenContentBarcodeImage() throws JposException, Printer printerDevice.printContent(contents, 0); //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printBarCode(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); @@ -814,7 +837,9 @@ public void printContent_WhenContentBarcodeText() throws JposException, PrinterE printerDevice.printContent(contents, 0); //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printBarCode(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); @@ -840,7 +865,9 @@ public void printContent_WhenContentImageText() throws JposException, PrinterExc printerDevice.printContent(contents, 0); //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printMemoryBitmap(anyInt(), any(), anyInt(), anyInt(), anyInt()); @@ -869,7 +896,9 @@ public void printContent_WhenContentBarcodeImageText() throws JposException, Pri printerDevice.printContent(contents, 0); //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).printBarCode(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); @@ -896,9 +925,12 @@ public void printContent_WhenTransactionPrintNormal_ThrowsException() throws Jpo //assert catch (JposException jposException) { + // getDevice: 1st from printContent sync, 2nd from paperEmptyCheck sync, 3rd from reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_TRANSACTION); + verify(mockPrinter, times(2)).transactionPrint(0, POSPrinterConst.PTR_TP_NORMAL); verify(mockPrinter).clearOutput(); return; } catch (PrinterException printerException) { @@ -917,7 +949,7 @@ public void printContent_WhenWaitForOutputToComplete_ThrowsException() throws Jp contents.add(textContent); printerDevice.setDeviceConnected(true); when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); - doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockDeviceListener).waitForOutputToComplete(); + doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockDeviceListener).waitForOutputToComplete(anyLong(), any(TimeUnit.class)); //act try { @@ -926,7 +958,9 @@ public void printContent_WhenWaitForOutputToComplete_ThrowsException() throws Jp //assert catch (JposException jposException) { + // getDevice: 1st from printContent sync, 2nd from paperEmptyCheck sync, 3rd from reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).clearOutput(); @@ -939,7 +973,7 @@ public void printContent_WhenWaitForOutputToComplete_ThrowsException() throws Jp } @Test - public void printContent_When111Exception_Reconnect() throws JposException, InterruptedException { + public void printContent_When111Exception_DoesNotReconnect() throws JposException, InterruptedException { //arrange List contents = new ArrayList<>(); TextContent textContent = new TextContent(); @@ -947,8 +981,8 @@ public void printContent_When111Exception_Reconnect() throws JposException, Inte contents.add(textContent); printerDeviceLock.setDeviceConnected(true); when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); - when(mockConnectLock.tryLock(printerDevice.getTryLockTimeout(), TimeUnit.SECONDS)).thenReturn(true); - doThrow(new JposException(JposConst.JPOS_E_FAILURE)).when(mockDeviceListener).waitForOutputToComplete(); + when(mockConnectLock.tryLock(printerDeviceLock.getTryLockTimeout(), TimeUnit.SECONDS)).thenReturn(true); + doThrow(new JposException(JposConst.JPOS_E_FAILURE)).when(mockDeviceListener).waitForOutputToComplete(anyLong(), any(TimeUnit.class)); //act try { @@ -957,12 +991,16 @@ public void printContent_When111Exception_Reconnect() throws JposException, Inte //assert catch (JposException jposException) { - verify(mockDynamicPrinter, times(6)).getDevice(); + assertEquals(JposConst.JPOS_E_FAILURE, jposException.getErrorCode()); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockDynamicPrinter).disconnect(); - verify(mockDynamicPrinter).connect(); - verify(mockConnectLock).unlock(); + verify(mockDynamicPrinter, never()).disconnect(); + verify(mockDynamicPrinter, never()).connect(); + // unlock called twice: once from reconnectR5Printer finally, once from printContent finally + verify(mockConnectLock, times(2)).unlock(); verify(mockPrinter).clearOutput(); return; } catch (PrinterException printerException) { @@ -973,7 +1011,7 @@ public void printContent_When111Exception_Reconnect() throws JposException, Inte } @Test - public void printContent_When105Exception_Reconnect() throws JposException, InterruptedException { + public void printContent_When105Exception_DoesNotReconnect() throws JposException, InterruptedException { //arrange List contents = new ArrayList<>(); TextContent textContent = new TextContent(); @@ -982,7 +1020,7 @@ public void printContent_When105Exception_Reconnect() throws JposException, Inte printerDeviceLock.setDeviceConnected(true); when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); when(mockConnectLock.tryLock(printerDeviceLock.getTryLockTimeout(), TimeUnit.SECONDS)).thenReturn(true); - doThrow(new JposException(JposConst.JPOS_E_DISABLED)).when(mockDeviceListener).waitForOutputToComplete(); + doThrow(new JposException(JposConst.JPOS_E_DISABLED)).when(mockDeviceListener).waitForOutputToComplete(anyLong(), any(TimeUnit.class)); //act try { @@ -991,15 +1029,19 @@ public void printContent_When105Exception_Reconnect() throws JposException, Inte //assert catch (JposException jposException) { - verify(mockDynamicPrinter, times(6)).getDevice(); + assertEquals(JposConst.JPOS_E_DISABLED, jposException.getErrorCode()); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockDynamicPrinter).disconnect(); - verify(mockDynamicPrinter).connect(); - verify(mockConnectLock).unlock(); + verify(mockDynamicPrinter, never()).disconnect(); + verify(mockDynamicPrinter, never()).connect(); + // unlock called twice: once from reconnectR5Printer finally, once from printContent finally + verify(mockConnectLock, times(2)).unlock(); verify(mockPrinter).clearOutput(); return; - } catch (PrinterException printerException) { + } catch (PrinterException printerException) { fail("Expected JposException, got PrinterException"); } @@ -1016,7 +1058,7 @@ public void printContent_When106Exception_Reconnect() throws JposException, Inte printerDeviceLock.setDeviceConnected(true); when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); when(mockConnectLock.tryLock(printerDeviceLock.getTryLockTimeout(), TimeUnit.SECONDS)).thenReturn(true); - doThrow(new JposException(JposConst.JPOS_E_ILLEGAL)).when(mockDeviceListener).waitForOutputToComplete(); + doThrow(new JposException(JposConst.JPOS_E_ILLEGAL)).when(mockDeviceListener).waitForOutputToComplete(anyLong(), any(TimeUnit.class)); //act try { @@ -1027,13 +1069,18 @@ public void printContent_When106Exception_Reconnect() throws JposException, Inte catch (JposException jposException) { fail("Expected PrinterException, got JposException"); } catch (PrinterException printerException) { - verify(mockDynamicPrinter, times(6)).getDevice(); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockDynamicPrinter).disconnect(); - verify(mockDynamicPrinter).connect(); - verify(mockConnectLock).unlock(); + // invalid-format path does NOT reconnect + verify(mockDynamicPrinter, never()).disconnect(); + verify(mockDynamicPrinter, never()).connect(); + // unlock called twice: once from reconnectR5Printer finally, once from printContent finally + verify(mockConnectLock, times(2)).unlock(); verify(mockPrinter).clearOutput(); + assertEquals(PrinterError.INVALID_FORMAT, printerException.getDeviceError()); return; } @@ -1050,7 +1097,7 @@ public void printContent_When114_207Exception_Reconnect() throws JposException, printerDeviceLock.setDeviceConnected(true); when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); when(mockConnectLock.tryLock(printerDevice.getTryLockTimeout(), TimeUnit.SECONDS)).thenReturn(true); - doThrow(new JposException(114, 207)).when(mockDeviceListener).waitForOutputToComplete(); + doThrow(new JposException(114, 207)).when(mockDeviceListener).waitForOutputToComplete(anyLong(), any(TimeUnit.class)); //act try { @@ -1061,13 +1108,18 @@ public void printContent_When114_207Exception_Reconnect() throws JposException, catch (JposException jposException) { fail("Expected PrinterException, got JposException"); } catch (PrinterException printerException) { - verify(mockDynamicPrinter, times(6)).getDevice(); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockDynamicPrinter).disconnect(); - verify(mockDynamicPrinter).connect(); - verify(mockConnectLock).unlock(); + // invalid-format path does NOT reconnect + verify(mockDynamicPrinter, never()).disconnect(); + verify(mockDynamicPrinter, never()).connect(); + // unlock called twice: once from reconnectR5Printer finally, once from printContent finally + verify(mockConnectLock, times(2)).unlock(); verify(mockPrinter).clearOutput(); + assertEquals(PrinterError.INVALID_FORMAT, printerException.getDeviceError()); return; } @@ -1121,28 +1173,20 @@ public void printContent_WhenClearOutput_ThrowsException() throws JposException when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockPrinter).clearOutput(); - //act - try { - printerDevice.printContent(contents, 0); - } + //act / assert + assertDoesNotThrow(() -> printerDevice.printContent(contents, 0)); - //assert - catch (JposException jposException) { - verify(mockDynamicPrinter, times(3)).getDevice(); - verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockPrinter).clearOutput(); - return; - } catch (PrinterException printerException) { - fail("Expected JposException, got PrinterException"); - } - - fail("Expected Exception, but got none"); + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer + verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer + verify(mockPrinter, times(2)).getPhysicalDeviceName(); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_TRANSACTION); + verify(mockPrinter).transactionPrint(0, POSPrinterConst.PTR_TP_NORMAL); + verify(mockPrinter).clearOutput(); } @Test public void printContent_WhenGetIsCheckInsertedFalse_DoesNotWithdrawCheck() throws JposException, PrinterException { - //arrange List contents = new ArrayList<>(); TextContent textContent = new TextContent(); textContent.setType(ContentType.TEXT); @@ -1151,11 +1195,11 @@ public void printContent_WhenGetIsCheckInsertedFalse_DoesNotWithdrawCheck() thro when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); printerDevice.setIsCheckInserted(false); - //act printerDevice.printContent(contents, 0); - //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer verify(mockDynamicPrinter, times(3)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).clearOutput(); @@ -1165,7 +1209,6 @@ public void printContent_WhenGetIsCheckInsertedFalse_DoesNotWithdrawCheck() thro @Test public void printContent_WhenGetIsCheckInsertedTrue_WithdrawsCheck() throws JposException, PrinterException { - //arrange List contents = new ArrayList<>(); TextContent textContent = new TextContent(); textContent.setType(ContentType.TEXT); @@ -1174,11 +1217,11 @@ public void printContent_WhenGetIsCheckInsertedTrue_WithdrawsCheck() throws Jpos when(mockPrinter.getPhysicalDeviceName()).thenReturn("NotR5"); printerDevice.setIsCheckInserted(true); - //act printerDevice.printContent(contents, 0); - //assert + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer, 4th withdrawCheck sync verify(mockDynamicPrinter, times(4)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer verify(mockPrinter, times(2)).getPhysicalDeviceName(); verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); verify(mockPrinter).clearOutput(); @@ -1188,7 +1231,6 @@ public void printContent_WhenGetIsCheckInsertedTrue_WithdrawsCheck() throws Jpos @Test public void printContent_WhenGetIsCheckInsertedTrue_WithdrawCheckThrowsError() throws JposException, PrinterException { - //arrange List contents = new ArrayList<>(); TextContent textContent = new TextContent(); textContent.setType(ContentType.TEXT); @@ -1198,48 +1240,34 @@ public void printContent_WhenGetIsCheckInsertedTrue_WithdrawCheckThrowsError() t printerDevice.setIsCheckInserted(true); doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockPrinter).beginRemoval(anyInt()); - //act - try { - printerDevice.printContent(contents, 0); - } + printerDevice.printContent(contents, 0); - //assert - catch (JposException jposException) { - verify(mockDynamicPrinter, times(4)).getDevice(); - verify(mockPrinter, times(2)).getPhysicalDeviceName(); - verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); - verify(mockPrinter).clearOutput(); - } - catch (PrinterException printerException) { - fail("Expected jposException, got printerException"); - } + // getDevice: 1st printContent sync, 2nd paperEmptyCheck sync, 3rd reconnectR5Printer, 4th withdrawCheck sync + verify(mockDynamicPrinter, times(4)).getDevice(); + // getPhysicalDeviceName: 1st paperEmptyCheck, 2nd reconnectR5Printer + verify(mockPrinter, times(2)).getPhysicalDeviceName(); + verify(mockPrinter, times(2)).transactionPrint(anyInt(), anyInt()); + verify(mockPrinter).clearOutput(); + verify(mockPrinter).beginRemoval(anyInt()); + verify(mockPrinter, never()).endRemoval(); } @Test - public void withdrawCheck_CallsThrough() throws JposException{ - //arrange - - //act + public void withdrawCheck_CallsThrough() throws JposException { printerDevice.withdrawCheck(); - //assert verify(mockDynamicPrinter).getDevice(); verify(mockPrinter).beginRemoval(0); verify(mockPrinter).endRemoval(); } @Test - public void withdrawCheck_beginRemovalThrowsError() throws JposException{ - //arrange + public void withdrawCheck_beginRemovalThrowsError() throws JposException { doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockPrinter).beginRemoval(0); - //act try { printerDevice.withdrawCheck(); - } - - //assert - catch (JposException jposException) { + } catch (JposException jposException) { verify(mockDynamicPrinter).getDevice(); verify(mockPrinter).beginRemoval(0); verify(mockPrinter, never()).endRemoval(); @@ -1250,17 +1278,12 @@ public void withdrawCheck_beginRemovalThrowsError() throws JposException{ } @Test - public void withdrawCheck_endRemovalThrowsError() throws JposException{ - //arrange + public void withdrawCheck_endRemovalThrowsError() throws JposException { doThrow(new JposException(JposConst.JPOS_E_EXTENDED)).when(mockPrinter).endRemoval(); - //act try { printerDevice.withdrawCheck(); - } - - //assert - catch (JposException jposException) { + } catch (JposException jposException) { verify(mockDynamicPrinter).getDevice(); verify(mockPrinter).beginRemoval(0); verify(mockPrinter).endRemoval(); @@ -1347,6 +1370,7 @@ public void statusUpdateOccurred_WhenCoverOpen_CallSetters() { //assert assertTrue(printerSpy.getWasDoorOpened()); assertFalse(printerSpy.getIsReconnectNeeded()); + verify(mockDeviceListener).statusUpdateOccurred(mockStatusUpdateEvent); } @Test @@ -1581,4 +1605,261 @@ public void unlock_CallsThrough() { verify(mockConnectLock).unlock(); assertFalse(printerDeviceLock.getIsLocked()); } + + // ------------------------------------------------------------------------- + // forceUnlock() tests + // ------------------------------------------------------------------------- + + /** + * When forceUnlock() is called and no thread holds the lock (lockOwnerThread == null), + * it should do nothing — no disconnect, no reconnect — and leave deviceConnected false. + */ + @Test + public void forceUnlock_WhenOwnerIsNull_DoesNotDisconnectOrReconnect() throws InterruptedException { + // arrange — printerDevice has a real ReentrantLock, nobody holds it + printerDevice.setDeviceConnected(true); + printerDevice.setAreListenersAttached(true); + + // act + printerDevice.forceUnlock(); + + // assert — immediate state changes + assertFalse(printerDevice.isConnected()); + assertFalse(printerDevice.getAreListenersAttached()); + assertFalse(printerDevice.getIsLocked()); + // disconnect() should never have been called on the DynamicDevice + verify(mockDynamicPrinter, never()).disconnect(); + } + + /** + * When forceUnlock() is called while a worker thread holds the lock, + * it interrupts that thread, the background thread disconnects the device, + * then polls until it can acquire the lock to reconnect. + * + * The background thread checks lockOwnerThread.get() == owner on entry BEFORE + * calling disconnect(). We use a latch in the doAnswer to confirm the background + * thread reached disconnect(), then release the worker. + */ + @Test + public void forceUnlock_WhenOwnerIsAlive_InterruptsAndDisconnectsAndReconnects() throws Exception { + // arrange + when(mockDynamicPrinter.connect()).thenReturn(DynamicDevice.ConnectionResult.CONNECTED); + + CountDownLatch workerHoldsLock = new CountDownLatch(1); + CountDownLatch backgroundStarted = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicBoolean workerInterrupted = new AtomicBoolean(false); + + // Signal when background thread actually enters disconnect() (past owner-check) + doAnswer(invocation -> { + backgroundStarted.countDown(); + return null; + }).when(mockDynamicPrinter).disconnect(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future worker = executor.submit(() -> { + printerDevice.tryLock(); + workerHoldsLock.countDown(); + try { + releaseWorker.await(); // hold even after interrupt so lockOwnerThread stays set + } catch (InterruptedException ie) { + workerInterrupted.set(true); + // do NOT re-interrupt — wait for explicit release so owner is still valid + try { releaseWorker.await(); } catch (InterruptedException ignored) {} + } + printerDevice.unlock(); + }); + + assertTrue(workerHoldsLock.await(2, TimeUnit.SECONDS), "Worker should hold lock within 2s"); + + // act + printerDevice.forceUnlock(); + + // Immediate state assertions (on calling thread) + assertFalse(printerDevice.isConnected()); + assertFalse(printerDevice.getAreListenersAttached()); + assertFalse(printerDevice.getIsLocked()); + + // Wait for background thread to enter disconnect() — confirms it passed the owner check + assertTrue(backgroundStarted.await(3, TimeUnit.SECONDS), "Background thread should start disconnect within 3s"); + + // Release the worker — it calls unlock(), freeing connectLock for the reconnect poll + releaseWorker.countDown(); + worker.get(3, TimeUnit.SECONDS); + executor.shutdown(); + + // Give background thread time to finish reconnect poll + Thread.sleep(3000); + + verify(mockDynamicPrinter, atLeastOnce()).disconnect(); + verify(mockDynamicPrinter, atLeastOnce()).connect(); + assertTrue(workerInterrupted.get(), "Worker thread should have been interrupted"); + } + + /** + * When the background disconnect throws an exception, forceUnlock() absorbs it + * and still proceeds to attempt reconnect. + */ + @Test + public void forceUnlock_WhenDisconnectThrows_StillAttemptsReconnect() throws Exception { + // arrange + CountDownLatch workerHoldsLock = new CountDownLatch(1); + CountDownLatch backgroundStarted = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + + doAnswer(invocation -> { + backgroundStarted.countDown(); + throw new RuntimeException("hardware error"); + }).when(mockDynamicPrinter).disconnect(); + when(mockDynamicPrinter.connect()).thenReturn(DynamicDevice.ConnectionResult.CONNECTED); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future worker = executor.submit(() -> { + printerDevice.tryLock(); + workerHoldsLock.countDown(); + try { + releaseWorker.await(); + } catch (InterruptedException ie) { + try { releaseWorker.await(); } catch (InterruptedException ignored) {} + } + printerDevice.unlock(); + }); + + assertTrue(workerHoldsLock.await(2, TimeUnit.SECONDS)); + printerDevice.forceUnlock(); + + assertTrue(backgroundStarted.await(3, TimeUnit.SECONDS), "Background thread should start disconnect within 3s"); + + releaseWorker.countDown(); + worker.get(3, TimeUnit.SECONDS); + executor.shutdown(); + + Thread.sleep(3000); + + verify(mockDynamicPrinter, atLeastOnce()).disconnect(); + // reconnect should still have been attempted even after the failed disconnect + verify(mockDynamicPrinter, atLeastOnce()).connect(); + } + + /** + * When the lock owner changes between forceUnlock() snapshotting it and the background + * thread executing its owner check, the background thread must skip disconnect to avoid + * disrupting the new legitimate owner (e.g. @Scheduled connect()). + * + * Achieved by releasing the lock BEFORE forceUnlock() is called so the background + * thread sees a different (null) owner immediately on entry. + */ + @Test + public void forceUnlock_WhenOwnerChangesBeforeBackgroundRuns_SkipsDisconnect() throws Exception { + // arrange — use a separate PrinterDevice with its own real lock + PrinterDevice device = new PrinterDevice(mockDynamicPrinter, mockDeviceListener); + + CountDownLatch workerHoldsLock = new CountDownLatch(1); + CountDownLatch workerReleased = new CountDownLatch(1); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future worker = executor.submit(() -> { + device.tryLock(); + workerHoldsLock.countDown(); + // Release immediately — simulates worker finishing just before background thread runs + device.unlock(); + workerReleased.countDown(); + }); + + assertTrue(workerHoldsLock.await(2, TimeUnit.SECONDS)); + // Wait for worker to actually release so lockOwnerThread is null when forceUnlock snapshots it + assertTrue(workerReleased.await(2, TimeUnit.SECONDS)); + + // act — owner is now null, so forceUnlock should take the early-exit path + device.forceUnlock(); + worker.get(2, TimeUnit.SECONDS); + executor.shutdown(); + + Thread.sleep(500); + + // Background thread should never have been spawned — disconnect must not be called + verify(mockDynamicPrinter, never()).disconnect(); + } + + /** + * When forceUnlock()'s reconnect attempt fails (connect returns NOT_CONNECTED), + * it logs the failure and leaves reconnect to the @Scheduled connect() fallback. + */ + @Test + public void forceUnlock_WhenReconnectFails_LogsFailureAndReliesOnScheduledConnect() throws Exception { + // arrange + CountDownLatch workerHoldsLock = new CountDownLatch(1); + CountDownLatch backgroundStarted = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + + doAnswer(invocation -> { + backgroundStarted.countDown(); + return null; + }).when(mockDynamicPrinter).disconnect(); + when(mockDynamicPrinter.connect()).thenReturn(DynamicDevice.ConnectionResult.NOT_CONNECTED); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future worker = executor.submit(() -> { + printerDevice.tryLock(); + workerHoldsLock.countDown(); + try { + releaseWorker.await(); + } catch (InterruptedException ie) { + try { releaseWorker.await(); } catch (InterruptedException ignored) {} + } + printerDevice.unlock(); + }); + + assertTrue(workerHoldsLock.await(2, TimeUnit.SECONDS)); + printerDevice.forceUnlock(); + + assertTrue(backgroundStarted.await(3, TimeUnit.SECONDS), "Background thread should start disconnect within 3s"); + + releaseWorker.countDown(); + worker.get(3, TimeUnit.SECONDS); + executor.shutdown(); + + Thread.sleep(3000); + + verify(mockDynamicPrinter, atLeastOnce()).disconnect(); + verify(mockDynamicPrinter, atLeastOnce()).connect(); + // printer remains not connected because connect() returned NOT_CONNECTED + assertFalse(printerDevice.isConnected()); + } + + /** + * forceUnlock() immediately resets isLocked, deviceConnected, and areListenersAttached + * on the calling thread, regardless of what the background thread does later. + */ + @Test + public void forceUnlock_ResetsStateImmediatelyOnCallingThread() throws Exception { + // arrange — lock is held by a worker so owner != null + CountDownLatch workerHoldsLock = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + + printerDevice.setDeviceConnected(true); + printerDevice.setAreListenersAttached(true); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future worker = executor.submit(() -> { + printerDevice.tryLock(); + workerHoldsLock.countDown(); + try { releaseWorker.await(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + printerDevice.unlock(); + }); + + assertTrue(workerHoldsLock.await(2, TimeUnit.SECONDS)); + + // act + printerDevice.forceUnlock(); + + // assert — synchronous state changes happen before background thread completes + assertFalse(printerDevice.isConnected(), "deviceConnected must be false immediately"); + assertFalse(printerDevice.getAreListenersAttached(), "areListenersAttached must be false immediately"); + assertFalse(printerDevice.getIsLocked(), "isLocked must be false immediately"); + + releaseWorker.countDown(); + worker.get(2, TimeUnit.SECONDS); + executor.shutdown(); + } }