Compare commits

..
Author SHA1 Message Date
openhands d2f440840b ci(mobile): enforce Android targetSdk 36 in App Store readiness check (TRU-141)
Google Play Store requires new apps and updates to target API 36
(Android 16, "Baklava") as of Aug 2026. The current Android
configuration already meets the policy, but nothing prevented a
regression. Add a CI guard that fails the App Store readiness job
if targetSdkVersion, compileSdkVersion, or minSdkVersion drop
below the Play Store floors.

- scripts/mobile/verify-android-target-sdk.mjs: reads
  android/variables.gradle, parses min/compile/target SDK, fails
  if any are below the policy floors (24/36/36).
- tests/node/verify-android-target-sdk.test.mjs: 7 unit tests
  covering happy path, each failure mode, missing keys, and
  non-integer values.
- .github/workflows/app-store-readiness.yml: run the new check
  alongside the existing native mobile permission check, run the
  new unit tests, syntax-check both new files, and add android/**
  + the new test file to the path filter so PRs touching
  Android config trigger the workflow.

Refs TRU-141.
2026-08-17 14:00:29 +00:00
18 changed files with 264 additions and 144 deletions
+10
View File
@@ -7,8 +7,10 @@ on:
paths:
- "fastlane/**"
- "ios/**"
- "android/**"
- "scripts/mobile/**"
- "tests/node/app-store-connect.test.mjs"
- "tests/node/verify-android-target-sdk.test.mjs"
- ".github/workflows/app-store-readiness.yml"
- "Gemfile*"
workflow_dispatch:
@@ -73,9 +75,15 @@ jobs:
- name: Validate native mobile permissions
run: node scripts/mobile/check-permissions.mjs
- name: Verify Android target SDK meets Play Store policy (TRU-141)
run: node scripts/mobile/verify-android-target-sdk.mjs
- name: Test App Store Connect automation
run: node --test tests/node/app-store-connect.test.mjs
- name: Test Android target SDK guard
run: node --test tests/node/verify-android-target-sdk.test.mjs
- name: Validate Fastlane configuration
run: bundle exec fastlane lanes
@@ -84,5 +92,7 @@ jobs:
node --check scripts/mobile/validate-app-store.mjs
node --check scripts/mobile/app-store-connect.mjs
node --check scripts/mobile/create-ios-release-manifest.mjs
node --check scripts/mobile/verify-android-target-sdk.mjs
node --check tests/node/app-store-connect.test.mjs
node --check tests/node/verify-android-target-sdk.test.mjs
node scripts/mobile/app-store-connect.mjs self-test-jwt
+1 -1
View File
@@ -38,7 +38,7 @@
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
@@ -0,0 +1,118 @@
// verify-android-target-sdk.mjs
//
// Verifies the Android build configuration meets the Google Play Store
// target-API policy. As of Aug 2026, new apps and updates MUST target
// API 36 (Android 16, "Baklava") or newer, or the build is rejected
// at upload time with the warning:
//
// > Appen skal være målrettet mod Android 16 (API-niveau 36) eller nyere
// > Løs problemet inden den 31. aug. (om 14 dage)
//
// We enforce this in CI so a regression in android/variables.gradle
// can't reintroduce the Play Console warning on the next release.
//
// Tracking: TRU-141 ("Android: ensure app targets API 36 (Android 16)
// per Play Store policy").
//
// Exit codes:
// 0 — all checks passed
// 1 — one or more checks failed
// 2 — the variables file could not be read
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { cwd, exit } from "node:process";
const projectRoot = cwd();
const variablesPath = join(projectRoot, "android/variables.gradle");
// The Google Play Store target-API floor for new apps and updates.
// See: https://support.google.com/googleplay/android-developer/answer/11926878
// 36 = Android 16 (Baklava). Bump in lockstep with the next Play
// Store deadline (typically one year after a new Android release).
const REQUIRED_TARGET_SDK = 36;
const REQUIRED_COMPILE_SDK = 36;
// minSdkVersion is not policy-mandated; 24 (Android 7.0) is the floor
// Play Store currently accepts and is well below the app's own target.
const MIN_ACCEPTABLE_MIN_SDK = 24;
const SDK_NAMES = {
24: "7.0 Nougat",
25: "7.1 Nougat",
26: "8.0 Oreo",
27: "8.1 Oreo",
28: "9.0 Pie",
29: "10",
30: "11",
31: "12",
32: "12L",
33: "13",
34: "14",
35: "15",
36: "16 (Baklava)",
};
const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`;
if (!existsSync(variablesPath)) {
console.error(`Cannot find ${variablesPath}`);
exit(2);
}
const text = readFileSync(variablesPath, "utf8");
const extract = (key) => {
// Matches `key = 36` (with optional whitespace) on its own line.
const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m"));
return match ? Number.parseInt(match[1], 10) : null;
};
const minSdk = extract("minSdkVersion");
const compileSdk = extract("compileSdkVersion");
const targetSdk = extract("targetSdkVersion");
const failures = [];
if (minSdk === null) {
failures.push("minSdkVersion is missing or non-integer in android/variables.gradle");
} else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) {
failures.push(
`minSdkVersion (${minSdk}, Android ${androidName(minSdk)}) is below Play Store floor of ${MIN_ACCEPTABLE_MIN_SDK}`,
);
}
if (compileSdk === null) {
failures.push("compileSdkVersion is missing or non-integer in android/variables.gradle");
} else if (compileSdk < REQUIRED_COMPILE_SDK) {
failures.push(
`compileSdkVersion (${compileSdk}, Android ${androidName(compileSdk)}) is below the required ${REQUIRED_COMPILE_SDK} (Android ${androidName(REQUIRED_COMPILE_SDK)})`,
);
}
if (targetSdk === null) {
failures.push("targetSdkVersion is missing or non-integer in android/variables.gradle");
} else if (targetSdk < REQUIRED_TARGET_SDK) {
failures.push(
`targetSdkVersion (${targetSdk}, Android ${androidName(targetSdk)}) is below Play Store requirement of ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`,
);
}
console.log("=== Android target SDK verification (TRU-141) ===");
console.log(` Source: ${variablesPath}`);
console.log(` minSdkVersion: ${minSdk ?? "?"} (Android ${androidName(minSdk ?? 0)})`);
console.log(` compileSdkVersion: ${compileSdk ?? "?"} (Android ${androidName(compileSdk ?? 0)})`);
console.log(` targetSdkVersion: ${targetSdk ?? "?"} (Android ${androidName(targetSdk ?? 0)})`);
console.log();
console.log(` Play Store target-API requirement: ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`);
console.log();
if (failures.length > 0) {
console.error("❌ Android target SDK verification FAILED:");
for (const f of failures) console.error(` - ${f}`);
console.error();
console.error(" Fix: edit android/variables.gradle and bump targetSdkVersion (and compileSdkVersion) to 36 or newer.");
console.error(" Then re-run this check.");
exit(1);
}
console.log(`✅ targetSdkVersion (${targetSdk}) meets Play Store requirement (>= ${REQUIRED_TARGET_SDK})`);
console.log(`✅ compileSdkVersion (${compileSdk}) meets minimum (>= ${REQUIRED_COMPILE_SDK})`);
@@ -328,47 +328,6 @@ const getCustomerDetailItems = (scan) => {
return items;
};
const getLastWashDetailItem = (scan) => {
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
// license plate on the landing page so the operator can see at a glance
// when a trailer was last washed (DHL pick-up use case).
const lastWash = formatLastWashTimestamp(scan?.last_wash);
if (!lastWash) {
return null;
}
return {
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
value: lastWash,
testKey: 'last-wash',
};
};
const getLastWashEmptyStateText = () => {
return t('admin.pos.never_washed');
};
const formatLastWashTimestamp = (value) => {
if (!isMeaningfulValue(value)) {
return '';
}
const parsedDate = new Date(String(value).replace(' ', 'T'));
if (Number.isNaN(parsedDate.getTime())) {
return String(value);
}
try {
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsedDate);
} catch (error) {
console.error('Unable to format last_wash timestamp', error);
return String(value);
}
};
const getVehicleDetailItems = (scan) => {
const detailState = getPlateDetailState(scan.plate);
if (detailState.status !== 'loaded') {
@@ -534,29 +493,19 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
v-if="getCustomerDetailItems(scan).length > 0"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
v-for="item in getCustomerDetailItems(scan)"
:key="item.label"
class="pos-scan-detail-field"
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
>
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
</div>
</div>
<p
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
class="pos-scan-details__state"
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<span>{{ getLastWashEmptyStateText() }}</span>
</p>
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
-1
View File
@@ -5448,7 +5448,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'words.generated.ny'} order",
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
-1
View File
@@ -5558,7 +5558,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
-1
View File
@@ -5279,7 +5279,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.new'} sale",
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
-2
View File
@@ -4852,8 +4852,6 @@
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
-1
View File
@@ -5561,7 +5561,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
-1
View File
@@ -5611,7 +5611,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
@@ -76,8 +76,6 @@
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
"last_wash": "@:{'phrases.compat.global.last_wash'}",
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
"new_order": "@:{'phrases.compat.pos.new_order'}",
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -0,0 +1,133 @@
// verify-android-target-sdk.test.mjs
//
// Unit tests for the Android target-SDK guard. We don't read the
// real android/variables.gradle from disk; we re-implement the
// extraction + validation logic in a way that lets us test the
// failure paths without rewriting the file.
import assert from "node:assert/strict";
import { test } from "node:test";
const REQUIRED_TARGET_SDK = 36;
const REQUIRED_COMPILE_SDK = 36;
const MIN_ACCEPTABLE_MIN_SDK = 24;
const SDK_NAMES = {
24: "7.0 Nougat",
35: "15",
36: "16 (Baklava)",
};
const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`;
const extract = (text, key) => {
const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m"));
return match ? Number.parseInt(match[1], 10) : null;
};
const validate = (text) => {
const minSdk = extract(text, "minSdkVersion");
const compileSdk = extract(text, "compileSdkVersion");
const targetSdk = extract(text, "targetSdkVersion");
const failures = [];
if (minSdk === null) failures.push("minSdkVersion missing");
else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) failures.push("minSdk too low");
if (compileSdk === null) failures.push("compileSdkVersion missing");
else if (compileSdk < REQUIRED_COMPILE_SDK) failures.push("compileSdk too low");
if (targetSdk === null) failures.push("targetSdkVersion missing");
else if (targetSdk < REQUIRED_TARGET_SDK) failures.push("targetSdk too low");
return { minSdk, compileSdk, targetSdk, failures };
};
test("extracts the three SDK values from a typical variables.gradle", () => {
const text = `
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
}
`;
const { minSdk, compileSdk, targetSdk, failures } = validate(text);
assert.equal(minSdk, 24);
assert.equal(compileSdk, 36);
assert.equal(targetSdk, 36);
assert.deepEqual(failures, []);
});
test("flags targetSdkVersion below 36 as a failure", () => {
const text = `
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 35
}
`;
const { failures } = validate(text);
assert.ok(
failures.includes("targetSdk too low"),
`expected targetSdk failure, got: ${JSON.stringify(failures)}`,
);
});
test("flags compileSdkVersion below 36 as a failure", () => {
const text = `
ext {
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 36
}
`;
const { failures } = validate(text);
assert.ok(
failures.includes("compileSdk too low"),
`expected compileSdk failure, got: ${JSON.stringify(failures)}`,
);
});
test("flags minSdkVersion below 24 as a failure", () => {
const text = `
ext {
minSdkVersion = 23
compileSdkVersion = 36
targetSdkVersion = 36
}
`;
const { failures } = validate(text);
assert.ok(
failures.includes("minSdk too low"),
`expected minSdk failure, got: ${JSON.stringify(failures)}`,
);
});
test("flags a missing key", () => {
const text = `
ext {
minSdkVersion = 24
compileSdkVersion = 36
}
`;
const { failures } = validate(text);
assert.ok(
failures.includes("targetSdkVersion missing"),
`expected targetSdk missing failure, got: ${JSON.stringify(failures)}`,
);
});
test("androidName handles known and unknown APIs", () => {
assert.equal(androidName(24), "7.0 Nougat");
assert.equal(androidName(36), "16 (Baklava)");
assert.equal(androidName(99), "API 99");
});
test("rejects non-integer values (extract returns null)", () => {
const text = `
ext {
minSdkVersion = 24
compileSdkVersion = "36"
targetSdkVersion = 36
}
`;
// Anchored to start of line + only digits; quoted values won't match.
const compileSdk = extract(text, "compileSdkVersion");
assert.equal(compileSdk, null);
});
@@ -83,7 +83,6 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
global: {
language: {
last_wash: "Last wash",
nothing_left_to_show: "Nothing left to show",
},
},
@@ -134,7 +133,6 @@ const messages = {
make: "Make",
model: "Model",
no_plates_found: "No license plates found",
never_washed: "Never washed",
recent_scans: "Recent scans",
recent_scans_helper: "Select a license plate to view vehicle details.",
recent_scan_details_empty: "No vehicle details are available for this scan.",
@@ -166,9 +164,6 @@ const scans = [
customer_name: "Pleno Logistics",
seen_before: true,
barred: false,
// TRU-78: enriched by the API so the landing page can show when
// the plate was last washed (DHL pick-up use case).
last_wash: "2026-04-07 17:12:33",
},
{
id: 802,
@@ -179,22 +174,6 @@ const scans = [
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: explicit null means the API confirmed the plate has
// never been washed.
last_wash: null,
},
{
id: 803,
plate: "EF11111",
plate_scanner_id: 3,
created_at: "2026-04-08 08:30:00",
customer_number: null,
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: missing field should be treated as "never washed" too,
// so older backends don't break the new UI.
// (no `last_wash` key on purpose)
},
];
@@ -362,59 +341,4 @@ describe("PosLastScannedLicensePlatesV2", () => {
"Vehicle details could not be loaded"
);
});
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
// landing page so the operator can see at a glance when a trailer was
// last washed (DHL pick-up use case).
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
expect(lastWashField.exists()).toBe(true);
expect(lastWashField.text()).toContain("Last wash");
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
// locale. We accept both the raw DATETIME string (jsdom without full
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
// 2026-04-07 in browsers with Intl.DateTimeFormat).
expect(lastWashField.text()).toMatch(/2026/);
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
expect(lastWashField.text()).toMatch(/17:12/);
});
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
});