Compare commits

...

28 Commits

Author SHA1 Message Date
dfacc39e57 test: add unit tests for DAOs 2026-01-18 18:36:34 -03:00
e125fb6e64 build: excludes html files from test coverage report 2026-01-18 17:59:59 -03:00
0a56fc6be0 fix: removes console.log 2026-01-18 15:13:05 -03:00
3467a574c1 fix: remove console.log 2026-01-18 00:32:28 -03:00
adc20904cc test(model/db): test instantiation 2026-01-17 22:56:19 -03:00
af84abe3f8 test(models/domain): test instantiation 2026-01-17 22:46:53 -03:00
4c51822bf0 test: extends coverage for sqlite service 2026-01-17 22:16:11 -03:00
8e53710c90 build: add testing coverage tool for vitest 2026-01-17 19:03:12 -03:00
4655ba4f64 feat(barcode reader): add wrapper for the webcomponent 2026-01-17 18:54:36 -03:00
ead327d44f feat(barcode reader): add close btn 2026-01-17 18:51:37 -03:00
a8ff28829d fix: increment z-index 2026-01-17 18:24:29 -03:00
888d636be6 refactor: db initialization 2026-01-17 16:05:22 -03:00
230ba670ab feat(persistance): add migration 2026-01-17 15:01:49 -03:00
fd763cc162 feat(persistance) : add DAO 2026-01-17 15:01:16 -03:00
8b462f98f6 feat(persistance): add db models 2026-01-17 15:00:24 -03:00
90233c7208 feat: add domain models 2026-01-17 14:58:39 -03:00
ab2aca1d97 feat(persistance): add additional ops for sqlite 2026-01-17 14:43:40 -03:00
fcee6e0b19 test: remove outdated test 2026-01-03 00:21:16 -03:00
5e1e453b0b feat(loading screen): add angular wrapper 2026-01-03 00:20:02 -03:00
a870e875c8 test: add missing dependency 2026-01-03 00:12:23 -03:00
49b9389532 feat: add barcode reader 2026-01-02 23:32:01 -03:00
7c6045f1b3 refactor: replace scan loading style loading 2026-01-02 22:42:05 -03:00
7952e5632a refactor: add loading screen on app initialization 2026-01-01 22:33:18 -03:00
92e426b316 feat(loading): add loading screen as web component 2026-01-01 22:14:58 -03:00
f095efd9e0 refactor: add global styles 2026-01-01 21:45:18 -03:00
1290130d8e feat(wa-sqlite): db initiallization 2026-01-01 14:00:30 -03:00
7be44bd8f2 feat(wa-sqlite): add mock cert, modifies npm dev command 2026-01-01 13:11:47 -03:00
0938dc861d build: add wrapped wa-sqlite for angular 2025-12-27 20:14:38 -03:00
58 changed files with 2900 additions and 56 deletions

View File

@@ -27,6 +27,16 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*.js",
"input": "./node_modules/angular-web-sqlite/src/lib/assets",
"output": "./sqlite-client/"
},
{
"glob": "**/*",
"input": "./node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/",
"output": "./sqlite-client/"
}
],
"styles": [
@@ -67,12 +77,21 @@
"buildTarget": "groceries-price-tracker:build:development"
}
},
"defaultConfiguration": "development"
"defaultConfiguration": "development",
"options": {
"headers": {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
}
},
"test": {
"builder": "@angular/build:unit-test"
"builder": "@angular/build:unit-test",
"options": {
"coverageExclude": ["**/*.html"]
}
}
}
}
}
}
}

25
cert/server.crt Normal file
View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIESzCCAzOgAwIBAgIUeHJl5gcLEDqZCFSNISmGK1L7HQswDQYJKoZIhvcNAQEL
BQAwgbQxCzAJBgNVBAYTAkFSMRUwEwYDVQQIDAxCdWVub3MgQWlyZXMxFTATBgNV
BAcMDEJ1ZW5vcyBBaXJlczETMBEGA1UECgwKR2FiaWxhbmRpYTEeMBwGA1UECwwV
R2FiaWxhbmRpYSBkZXZlbG9wZXJzMRMwEQYDVQQDDApHYWJpbGFuZGlhMS0wKwYJ
KoZIhvcNAQkBFh5nYWJyaWVsLmRlbG9zcmlvc0B0dXRhbWFpbC5jb20wHhcNMjUx
MjI3MjMyOTM1WhcNMjYxMjI3MjMyOTM1WjCBtDELMAkGA1UEBhMCQVIxFTATBgNV
BAgMDEJ1ZW5vcyBBaXJlczEVMBMGA1UEBwwMQnVlbm9zIEFpcmVzMRMwEQYDVQQK
DApHYWJpbGFuZGlhMR4wHAYDVQQLDBVHYWJpbGFuZGlhIGRldmVsb3BlcnMxEzAR
BgNVBAMMCkdhYmlsYW5kaWExLTArBgkqhkiG9w0BCQEWHmdhYnJpZWwuZGVsb3Ny
aW9zQHR1dGFtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AJzeRKX60Aidx2f9Vej9JagIG3B3dPMt5igVCbw8rra6k5/kUH0Vyk4TLfI9UBLX
7T3i0c3pYZPaBm1dimNRkZEKY8QauwGfGuSR1hhyr4vwhhLaAXqnhm7CZ5fwN+PA
WySfEqXDuyeURSLvaHdNztLFMaDnA1C0cq0waQhJTVe1xMmcFkW76qRWEkKb30oq
FGF5GNYKVXfrzWYD4ZxW98tGrz6AhgR6XkF+aSmotKoJn6mUWQnmsnjKmBRXcaqx
j6lJgcpdxWh8FitBB2fLUOnOZ9avcDZv9wVvVZehUgcHiAOvSIAvANdS3bKFrQcc
6ZV/WEI0ZRQTqZ8hXufXW+kCAwEAAaNTMFEwHQYDVR0OBBYEFDyTMsJj8YEoJcAe
FJ1LzpuV596+MB8GA1UdIwQYMBaAFDyTMsJj8YEoJcAeFJ1LzpuV596+MA8GA1Ud
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAI0UY4KLPzlc1y6VR6flFsKA
trTBdkyICoXf2rSnRuSNS9c9cQN6j9MC3D3oymw1e3NftCh0a/Mem5A24hwK0CPQ
ngGoGJZrfj5QHAoJ2pmIrlsmBTL66ZkvRYll/kwKHUZ3ePjaQp+JafAuhxABFhaG
geC+c9gzSe3cIPuU6dd14AyFJewRu+VqY4fKIoHl2kwvdxzTe5/Fua6b084dbZMs
GaEfYMkp/BuSLZVpbkliE6qq2w9qYW9yMEp9DA7nsQW+bauayTgcVWswJCzCZop2
zaoVfNI3cyUIFDQxFNmr8uyZc9bQ+boeZ9NB1yKIWE+0RP4LrznuirGtCpHZiuo=
-----END CERTIFICATE-----

28
cert/server.key Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCc3kSl+tAIncdn
/VXo/SWoCBtwd3TzLeYoFQm8PK62upOf5FB9FcpOEy3yPVAS1+094tHN6WGT2gZt
XYpjUZGRCmPEGrsBnxrkkdYYcq+L8IYS2gF6p4ZuwmeX8DfjwFsknxKlw7snlEUi
72h3Tc7SxTGg5wNQtHKtMGkISU1XtcTJnBZFu+qkVhJCm99KKhRheRjWClV3681m
A+GcVvfLRq8+gIYEel5BfmkpqLSqCZ+plFkJ5rJ4ypgUV3GqsY+pSYHKXcVofBYr
QQdny1DpzmfWr3A2b/cFb1WXoVIHB4gDr0iALwDXUt2yha0HHOmVf1hCNGUUE6mf
IV7n11vpAgMBAAECggEABkNhnkxAriIOlM+qnx4sY2R0zq1rqqH0hI6Cev+Ol5Z6
JRzVPIlNhdyCLcqA6J+ycJFz1bLccIN4qjkyjftIbUVq7P4xj+5YVolJcQ8d2d/a
mo9rFl3woiTPmg4UooVK86VeSzwLZu3RQi8o4/U5WB+uIZctAgs9C5PZPbl02xbu
KYO6ByNB/fksELlJGrw+46hjDxg53a6TPOtDgqGlyIweJIS3LyznvcP1qEYoXP90
kgFu141hC4HRYprYTXykuUx8e5y/QmOQdM6of6vkT2SVFyN4YYupUsKJpRrHNDJM
WcjXVVLs8vYhHiDJ41MnV4zoHUNgc78fCn/RE2l0AQKBgQDLGW5C5vEsC4cAQWVR
ppCED5MtHblEH153MxXyNnnrIzCsjVCL3HQtsCcxGFxXyTMCEqpc1HdwYsMAn/PJ
vx8W06VdHIoUkKUDf+nvVvlSdiUBWZONKOJDG06d10YdVOWr1xB7UyRMz+KE6yxe
svIYDs53TQckH6fGx8ADAsUPwQKBgQDFuitC8obNWOgtaJb4NhbDZh9xSDQDyd3M
CoHiTGDjviQYrN0sUs05w4bB/b/gH8vUsL3xJ6ajo1k6iSxCEyKYFPwVzG00nNz9
oZQD0HjmtyU2Ht9Gz1OidXUkUT1/zXG0YVWTw3xcL1CkBVHeddnPwu6+LWItaI9b
BOMhAj1WKQKBgF0doLbdqQ73jgKo+Onxgup5NZIGwa0g8K+X5WTyYv1SWfuSoq4s
+bsEu0NAFv5Mia1Wn3MGGmiVbzA3JY+Gp5tQl81Ty17YXj93guqvpomPDzJKPmMG
ro3z1Bx72XKTPOWHKdBQ8yCgYwtrwyD7zBEJoNGDqFWAieySIk9/EphBAoGAHnT5
2vsetpzeTrhQoPU79mdRqpJzqK28o4Ru01vuhMYyKzbdbslmYWJz7IfYuX5MWHvN
FkuESKqNwQ2GKrtl1cVNu1Hc9IDBLbRo51mCdg96BOcmf3LKMDkljS2SapBL1nwz
wWoYSt7i7hD/tmOy5GTjf5ngCJyVkHClR91fc1kCgYAvlB1BodeZApt1dFjLEz8F
l9/VzBZrMNf1Py5rHl2wFT+xM/4gXArjvKkBSCO9dqRbvmfvFTBVuI+nd2a0CoVH
dtfc/4GzGNHzjcocScZG+UIDMLvDXpPZicEGcc2IERjlopDH/YuBlYsBd5n+19zK
ZA5FJdXg9HW/iJMhuLyh6A==
-----END PRIVATE KEY-----

333
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"angular-web-sqlite": "^1.0.34",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -21,6 +22,7 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
@@ -948,6 +950,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1927,6 +1939,41 @@
}
}
},
"node_modules/@ionic/angular": {
"version": "8.7.15",
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-8.7.15.tgz",
"integrity": "sha512-2FFmOCoE3CRHR/WAsx+DX084ywxCNEdqrDuqy0iUmi1jbXrYFcC3xnbvBLKRsQtN56nFLS07DkL6wLCCOY7M4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ionic/core": "8.7.15",
"ionicons": "^8.0.13",
"jsonc-parser": "^3.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@angular/router": ">=16.0.0",
"rxjs": ">=7.5.0",
"zone.js": ">=0.13.0"
}
},
"node_modules/@ionic/core": {
"version": "8.7.15",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.15.tgz",
"integrity": "sha512-u1w9c6dx2iuatXIW5X1JY0ighDhQPjBwOHZsrOcnpm891pktuEjJDdyhDulWFa6kKVkXw1q7khwxXBEurvKc2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
"tslib": "^2.1.0"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -3899,12 +3946,45 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.44.0-build2",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.44.0-build2.tgz",
"integrity": "sha512-zFfqHoYxuGQ15+A9W00JGQEfQ+f1b+NNpZRD98I31G0x7es+IiGIh8n52vBoub+q9bZLW++Xa6T1QXuw4eGSDQ==",
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stencil/core": {
"version": "4.38.0",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.38.0.tgz",
"integrity": "sha512-oC3QFKO0X1yXVvETgc8OLY525MNKhn9vISBrbtKnGoPlokJ6rI8Vk1RK22TevnNrHLI4SExNLbcDnqilKR35JQ==",
"license": "MIT",
"peer": true,
"bin": {
"stencil": "bin/stencil"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=7.10.0"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.34.9",
"@rollup/rollup-darwin-x64": "4.34.9",
"@rollup/rollup-linux-arm64-gnu": "4.34.9",
"@rollup/rollup-linux-arm64-musl": "4.34.9",
"@rollup/rollup-linux-x64-gnu": "4.34.9",
"@rollup/rollup-linux-x64-musl": "4.34.9",
"@rollup/rollup-win32-arm64-msvc": "4.34.9",
"@rollup/rollup-win32-x64-msvc": "4.34.9"
}
},
"node_modules/@tufjs/canonical-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -3978,17 +4058,48 @@
"vite": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz",
"integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.17",
"ast-v8-to-istanbul": "^0.3.10",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.17",
"vitest": "4.0.17"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz",
"integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"@vitest/spy": "4.0.17",
"@vitest/utils": "4.0.17",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
@@ -3997,13 +4108,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz",
"integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.16",
"@vitest/spy": "4.0.17",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -4034,9 +4145,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz",
"integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4047,13 +4158,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz",
"integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.16",
"@vitest/utils": "4.0.17",
"pathe": "^2.0.3"
},
"funding": {
@@ -4061,13 +4172,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz",
"integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"@vitest/pretty-format": "4.0.17",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -4086,9 +4197,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz",
"integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==",
"dev": true,
"license": "MIT",
"funding": {
@@ -4096,13 +4207,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz",
"integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"@vitest/pretty-format": "4.0.17",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -4211,6 +4322,22 @@
"node": ">= 14.0.0"
}
},
"node_modules/angular-web-sqlite": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/angular-web-sqlite/-/angular-web-sqlite-1.0.34.tgz",
"integrity": "sha512-OfV/vA8FEBfb2SjbPB5529ZcjalAUf6WiI+RZcW3V/N1OgRNbM7HrxmIuYJCQ2cm8ZYO6LgOQRmN+ufeAIdM1g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@sqlite.org/sqlite-wasm": "3.44.0-build2",
"tslib": "^2.6.2"
},
"peerDependencies": {
"@angular/common": "13.x || 14.x || 15.x || 16.x || 17.x || 18.x || 19.x || 20.x || 21.x || 22.x",
"@angular/core": "13.x || 14.x || 15.x || 16.x || 17.x || 18.x || 19.x || 20.x || 21.x || 22.x",
"@ionic/angular": "5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/ansi-escapes": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
@@ -4263,6 +4390,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
"integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@@ -5539,6 +5685,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5601,6 +5757,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
@@ -5754,6 +5917,16 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ionicons": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.0.13.tgz",
"integrity": "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stencil/core": "^4.35.3"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -5916,6 +6089,35 @@
"node": ">=10"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
@@ -6020,7 +6222,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsonparse": {
@@ -6208,6 +6409,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-fetch-happen": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz",
@@ -7983,6 +8212,19 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -8862,19 +9104,19 @@
}
},
"node_modules/vitest": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz",
"integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
"@vitest/pretty-format": "4.0.16",
"@vitest/runner": "4.0.16",
"@vitest/snapshot": "4.0.16",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"@vitest/expect": "4.0.17",
"@vitest/mocker": "4.0.17",
"@vitest/pretty-format": "4.0.17",
"@vitest/runner": "4.0.17",
"@vitest/snapshot": "4.0.17",
"@vitest/spy": "4.0.17",
"@vitest/utils": "4.0.17",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
@@ -8902,10 +9144,10 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.16",
"@vitest/browser-preview": "4.0.16",
"@vitest/browser-webdriverio": "4.0.16",
"@vitest/ui": "4.0.16",
"@vitest/browser-playwright": "4.0.17",
"@vitest/browser-preview": "4.0.17",
"@vitest/browser-webdriverio": "4.0.17",
"@vitest/ui": "4.0.17",
"happy-dom": "*",
"jsdom": "*"
},
@@ -9291,6 +9533,13 @@
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/zone.js": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz",
"integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==",
"license": "MIT",
"peer": true
}
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --host 0.0.0.0 --ssl true --ssl-key \"./cert/server.key\" --ssl-cert \"./cert/server.crt\"",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
@@ -29,6 +29,7 @@
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"angular-web-sqlite": "^1.0.34",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -36,8 +37,9 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
}

View File

@@ -0,0 +1,126 @@
:host {
display: flex;
--video-width: 100%;
--video-height: 100dvh;
--video-background-color: #cccccc;
--dialog-background-color: #ffffff;
--dialog-backdrop-background-color: #cecece;
}
#container {
display: none;
position: relative;
width: var(--video-width);
background-color: #000000;
}
.close-btn {
background: none;
border-radius: 50%;
border-style: none;
color: white;
font-size: 24px;
font-weight: bold;
margin-right: 12px;
margin-top: 12px;
position: absolute;
right: 0;
top: 0;
z-index: 101;
}
@media screen and (min-width: 1024px) {
#container {
min-width: 400px;
min-height: 300px;
}
}
#video-container {
position: relative;
overflow: hidden;
height: var(--video-height);
max-height: var(--video-height);
}
video {
width: 100%;
display: block;
object-fit: cover;
height: 100%;
}
canvas {
position: absolute;
top: 30%;
width: 100%;
height: 25%;
}
#data-canvas {
visibility: hidden;
}
#frame {
opacity: 1;
position: absolute;
top: 30%;
left: 0;
width: 100%;
height: 25%;
background-color: transparent;
transform-origin: center center;
transform: scale(0.9);
transition: transform .3s ease-out;
}
:host([capturing]) #frame {
transform: scale(0.5);
}
:host([no-frame]) #frame {
display: none;
}
.corner {
position: absolute;
width: 50px;
height: 50px;
border: 3px solid #ee0b0b;
clip-path: polygon(0 48px, 48px 48px, 48px 0, 0 0);
}
.top-left {
top: 0;
left: 0;
}
.top-right {
top: 0;
right: 0;
transform: rotate(90deg);
}
.bottom-right {
bottom: 0;
right: 0;
transform: rotate(180deg);
}
.bottom-left {
bottom: 0;
left: 0;
transform: rotate(-90deg);
}
.overlay {
position: absolute;
top: 31%;
left: 2%;
width: 96%;
height: 23%;
border-radius: 2%;
z-index: 100;
box-shadow: 0 0 0 200vmax rgba(0, 0, 0, 0.6);
pointer-events: none;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,21 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { WebSqlite } from 'angular-web-sqlite';
import { Sqlite } from './services/sqlite';
import { tables } from '../migrations/20260117';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(async () => {
const sqlite = inject(Sqlite);
await sqlite.initializeDatabase('gptdb');
await sqlite.batchSqlOperations(tables);
await sqlite.executeQuery('PRAGMA foreign_keys = ON;');
document.dispatchEvent(new CustomEvent('ng-boot'));
}),
{provide: WebSqlite, useClass: WebSqlite},
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]

View File

@@ -14,10 +14,4 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, groceries-price-tracker');
});
});

View File

@@ -0,0 +1,6 @@
<barcode-reader
#reader
(result)="this.result.emit($event)"
(scan-status)="this.scanStatus.emit($event)"
(scan-permission-denied)="this.scanPermissionDenied.emit()"
/>

View File

@@ -0,0 +1,6 @@
barcode-reader {
display: block;
width: 100%;
position: absolute;
top: 0;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BarCodeReaderWrapper } from './bar-code-reader';
describe('BarCodeReaderWrapper', () => {
let component: BarCodeReaderWrapper;
let fixture: ComponentFixture<BarCodeReaderWrapper>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BarCodeReaderWrapper]
})
.compileComponents();
fixture = TestBed.createComponent(BarCodeReaderWrapper);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, effect, ElementRef, input, output, signal, viewChild } from '@angular/core';
import { DetectedBarcode } from '../../types/globalThis';
import { BarcodeReader } from '../../web-components/barcode-reader';
@Component({
selector: 'app-bar-code-reader',
templateUrl: './bar-code-reader.html',
styleUrl: './bar-code-reader.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class BarCodeReaderWrapper {
private readonly ref = viewChild<ElementRef<BarcodeReader>>('reader');
protected readonly result = output<Event & {detail?: {code: DetectedBarcode | null}}>();
protected readonly scanStatus = output<Event & {detail?: {state: ScanState}}>();
protected readonly scanPermissionDenied = output();
scan = input({scan: false})
constructor() {
effect(() => this.scan().scan && this.ref()?.nativeElement.scan())
}
}
export type ScanState = 'loading'|'started'|'stopped';

View File

@@ -0,0 +1 @@
<scan-loading-screen></scan-loading-screen>

View File

@@ -0,0 +1,7 @@
:host {
display: block;
height: 100%;
position: absolute;
width: 100%;
z-index: 999;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScanLoadingScreen } from './scan-loading-screen';
describe('ScanLoadingScreen', () => {
let component: ScanLoadingScreen;
let fixture: ComponentFixture<ScanLoadingScreen>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScanLoadingScreen]
})
.compileComponents();
fixture = TestBed.createComponent(ScanLoadingScreen);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-scan-loading-screen',
imports: [],
templateUrl: './scan-loading-screen.html',
styleUrl: './scan-loading-screen.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ScanLoadingScreen {
}

86
src/app/dao/BaseDAO.ts Normal file
View File

@@ -0,0 +1,86 @@
import { inject } from '@angular/core';
import { Sqlite } from '../services/sqlite';
import { QueryResult } from '../types/sqlite.type';
export abstract class BaseDAO<T extends Object, K extends Object, U> {
protected readonly sqlite = inject(Sqlite);
protected readonly tableName: string;
constructor(tableName: string) {
this.tableName = tableName.toLowerCase();
}
async insert(entity: T) {
const params = [];
let sql = `INSERT INTO ${this.tableName} ( `;
let values = `VALUES ( `;
let dbModel = this.toDB(entity);
for (let key in dbModel) {
if (dbModel[key] !== undefined) {
sql += `${key}, `;
params.push(dbModel[key]);
values += '?, ';
}
}
sql = sql.slice(0, -2);
sql += ' ) ';
values = values.slice(0, -2);
values += ' );';
sql = sql + values;
const result: { rows: never[] } = await this.sqlite.executeQuery(sql, params);
return result;
}
async findBy(values: Partial<K>) {
let sql = `SELECT * FROM ${this.tableName} `;
const params: any[] = [];
const whereSql = this.whereClauseGenerator(values, params);
sql += whereSql;
sql += ';';
const queryResults: QueryResult<U> = await this.sqlite.executeQuery(sql, params);
return queryResults.rows.map(this.fromDB);
}
async findAll() {
const queryResults: QueryResult<U> = await this.sqlite.executeQuery(
`SELECT * FROM ${this.tableName.toLowerCase()}`
);
return queryResults.rows.map(this.fromDB);
}
async update(values: T, where: Partial<K>) {
const params: any[] = [];
let sql = `UPDATE ${this.tableName} SET `;
let updates = '';
let dbModel = this.toDB(values);
for (let key in dbModel) {
if (dbModel[key] !== undefined) {
updates += `${key} = ?, `;
params.push(dbModel[key]);
}
}
updates = updates.slice(0, -2);
updates += ' ';
sql += updates;
const whereSql = this.whereClauseGenerator(where, params);
sql += whereSql + ';';
const result: { rows: never[] } = await this.sqlite.executeQuery(sql, params);
return result;
}
protected whereClauseGenerator(values: Partial<K>, params: any[], alias?: string) {
let sql = `WHERE `;
const objLength = Object.keys(values).length;
let i = 0;
for (let key in values) {
i += 1;
sql += `${alias ? alias + '.' : ''}${key} = ? `;
params.push(values[key]);
if (i < objLength) sql += `AND `;
}
return sql;
}
protected abstract toDB(model: T): K;
protected abstract fromDB(queryResult: U): T;
}

View File

@@ -0,0 +1,73 @@
import { TestBed } from '@angular/core/testing';
import { ChainDAO } from './ChainDAO';
import { Sqlite } from '../services/sqlite';
import { vi } from 'vitest';
import { QueryResult } from '../types/sqlite.type';
import { Chain } from '../models/Chain';
describe('ChainDAO', () => {
let service: ChainDAO;
let sqlite: Partial<Sqlite>;
let CHAIN_MOCK: Chain;
let RESULTS_MOCK: QueryResult<Chain>;
beforeEach(() => {
CHAIN_MOCK = new Chain('mock', 'mock.png', 1);
RESULTS_MOCK = { rows: [CHAIN_MOCK] };
sqlite = {
executeQuery: vi.fn().mockResolvedValue(RESULTS_MOCK),
};
TestBed.configureTestingModule({
providers: [{ provide: Sqlite, useValue: sqlite }],
});
service = TestBed.inject(ChainDAO);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should receive mapped results on findAll', async () => {
const result = await service.findAll();
if (result.length > 0) {
expect(result[0]).toEqual(CHAIN_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should receive mapped results on findBy', async () => {
const result = await service.findBy({ id: CHAIN_MOCK.id, name: CHAIN_MOCK.name });
if (result.length > 0) {
expect(result[0]).toEqual(CHAIN_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should call executeQuery with object fields on insert', async () => {
const expectedSql = 'INSERT INTO chain ( name, image, id ) VALUES ( ?, ?, ? );';
const expectedParams: any[] = [];
for (let key in CHAIN_MOCK) {
expectedParams.push(CHAIN_MOCK[<keyof Chain>key]);
}
await service.insert(CHAIN_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
it('should call executeQuery with object fields and where values on update', async () => {
const expectedParams: any[] = [];
CHAIN_MOCK.name = 'Updated mock';
for (let key in CHAIN_MOCK) {
expectedParams.push(CHAIN_MOCK[<keyof Chain>key]);
}
const WHERE_MOCK: Partial<Chain> = { id: 1 };
expectedParams.push(WHERE_MOCK.id);
const expectedSql = 'UPDATE chain SET name = ?, image = ?, id = ? WHERE id = ? ;';
await service.update(CHAIN_MOCK, WHERE_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
});

21
src/app/dao/ChainDAO.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { Chain } from '../models/Chain';
import { BaseDAO } from './BaseDAO';
import { DBChain } from '../models/db/DBChain';
@Injectable({
providedIn: 'root',
})
export class ChainDAO extends BaseDAO<Chain, DBChain, DBChain> {
constructor() {
super(Chain.name);
}
override toDB(model: Chain): DBChain {
return new DBChain(model.name, model.image, model.id);
}
override fromDB(qResult: DBChain): Chain {
return new Chain(qResult.name, qResult.image, qResult.id);
}
}

View File

@@ -0,0 +1,27 @@
import { QueryResult } from '../types/sqlite.type';
import { BaseDAO } from './BaseDAO';
export abstract class ComposedDAO<T extends Object, K extends Object, U> extends BaseDAO<T, K, U> {
protected readonly JOIN_SQL: string;
protected readonly tableAlias: string;
constructor(tableName: string, joinSql: string, tableAlias: string) {
super(tableName);
this.JOIN_SQL = joinSql;
this.tableAlias = tableAlias;
}
override async findAll() {
const result: QueryResult<U> = await this.sqlite.executeQuery(this.JOIN_SQL);
return result.rows.map(this.fromDB);
}
override async findBy(values: Partial<K>): Promise<T[]> {
let sql = this.JOIN_SQL.slice(0, -1) + ' ';
const params: any[] = [];
const whereSql = this.whereClauseGenerator(values, params, this.tableAlias);
sql += whereSql + ';';
const result: QueryResult<U> = await this.sqlite.executeQuery(sql, params);
return result.rows.map(this.fromDB);
}
}

View File

@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { EstablishmentDAO } from './EstablishmentDAO';
import { Sqlite } from '../services/sqlite';
import { Establishment } from '../models/Establishment';
import { Chain } from '../models/Chain';
import { EstablishmentQueryResult } from '../types/sqlite.type';
describe('EstablishmentDAO', () => {
let service: EstablishmentDAO;
let sqlite: Partial<Sqlite>;
let ESTABLISHMENT_MOCK: Establishment;
let QUERY_RESULT_MOCK: EstablishmentQueryResult;
beforeEach(() => {
QUERY_RESULT_MOCK = {
address: 'mock street',
image: 'mock.jpg',
name: 'mock',
chain_id: 1,
id: 1,
};
ESTABLISHMENT_MOCK = new Establishment(
new Chain(QUERY_RESULT_MOCK.name, QUERY_RESULT_MOCK.image, QUERY_RESULT_MOCK.chain_id),
QUERY_RESULT_MOCK.address,
QUERY_RESULT_MOCK.id,
);
sqlite = {
executeQuery: vi.fn().mockResolvedValue({ rows: [QUERY_RESULT_MOCK] }),
};
TestBed.configureTestingModule({
providers: [{ provide: Sqlite, useValue: sqlite }],
});
service = TestBed.inject(EstablishmentDAO);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should receive mapped results on findAll', async () => {
const result = await service.findAll();
if (result.length > 0) {
expect(result[0]).toEqual(ESTABLISHMENT_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should receive mapped results on findBy', async () => {
const result = await service.findBy({ id: 1 });
if (result.length > 0) {
expect(result[0]).toEqual(ESTABLISHMENT_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should call executeQuery with object fields on insert', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
const expectedSql = 'INSERT INTO establishment ( address, chain_id ) VALUES ( ?, ? );';
const expectedParams = [ESTABLISHMENT_MOCK.address, ESTABLISHMENT_MOCK.chain.id];
ESTABLISHMENT_MOCK.id = undefined;
await service.insert(ESTABLISHMENT_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
it('should throw if toDB is called with a chain that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
ESTABLISHMENT_MOCK.id = undefined;
ESTABLISHMENT_MOCK.chain.id = undefined;
expect(service.insert(ESTABLISHMENT_MOCK)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Establishment } from '../models/Establishment';
import { Chain } from '../models/Chain';
import { DBEstablishment } from '../models/db/DBEstablishment';
import { ComposedDAO } from './ComposedDAO';
import { EstablishmentQueryResult } from '../types/sqlite.type';
@Injectable({
providedIn: 'root',
})
export class EstablishmentDAO extends ComposedDAO<
Establishment,
DBEstablishment,
EstablishmentQueryResult
> {
constructor() {
super(
Establishment.name,
`SELECT e.id, e.address, chain_id, c.name, c.image FROM establishment e JOIN chain c ON c.id = chain_id;`,
'e',
);
}
protected override toDB(model: Establishment): DBEstablishment {
if (!model.chain.id) throw new Error('Chain id is required');
return new DBEstablishment(model.address, model.chain.id, model.id);
}
protected override fromDB(qR: EstablishmentQueryResult): Establishment {
const chain = new Chain(qR.name, qR.image, qR.chain_id);
return new Establishment(chain, qR.address, qR.id);
}
}

View File

@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { ProductDAO } from './ProductDAO';
import { Sqlite } from '../services/sqlite';
import { Product } from '../models/Product';
describe('ProductDAO', () => {
let service: ProductDAO;
let sqlite: Partial<Sqlite>;
let PRODUCT_MOCK: Product;
beforeEach(() => {
PRODUCT_MOCK = new Product('12121112', 'mock', 'mock.jpg', 1);
sqlite = {
executeQuery: vi.fn().mockResolvedValue({ rows: [PRODUCT_MOCK] }),
};
TestBed.configureTestingModule({
providers: [{ provide: Sqlite, useValue: sqlite }],
});
service = TestBed.inject(ProductDAO);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should receive mapped results on findAll', async () => {
const result = await service.findAll();
if (result.length > 0) {
expect(result[0]).toEqual(PRODUCT_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should call executeQuery with object fields on insert', async () => {
PRODUCT_MOCK.id = undefined;
const expectedSql = 'INSERT INTO product ( barcode, name, image ) VALUES ( ?, ?, ? );';
const expectedParams: any[] = [PRODUCT_MOCK.barcode, PRODUCT_MOCK.name, PRODUCT_MOCK.image];
await service.insert(PRODUCT_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
});

21
src/app/dao/ProductDAO.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { Product } from '../models/Product';
import { BaseDAO } from './BaseDAO';
import { DBProduct } from '../models/db/DBProduct';
@Injectable({
providedIn: 'root',
})
export class ProductDAO extends BaseDAO<Product, DBProduct, DBProduct> {
constructor() {
super(Product.name);
}
protected override toDB(model: Product): DBProduct {
return new Product(model.barcode, model.name, model.image, model.id);
}
protected override fromDB(qR: DBProduct): Product {
return new DBProduct(qR.barcode, qR.name, qR.image, qR.id);
}
}

View File

@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { ProductEstablishmentDAO } from './ProductEstablishmentDAO';
import { Sqlite } from '../services/sqlite';
import { ProductEstablishment } from '../models/ProductEstablisment';
import { Product } from '../models/Product';
import { Establishment } from '../models/Establishment';
import { Chain } from '../models/Chain';
import { ProductEstablishmentQueryResult } from '../types/sqlite.type';
describe('ProductEstablishmentDAO', () => {
let service: ProductEstablishmentDAO;
let sqlite: Partial<Sqlite>;
let PRODUCT_ESTABLISHMENT_MOCK: ProductEstablishment;
let PRODUCT_ESTABLISHMENT_QUERY_RESULT: ProductEstablishmentQueryResult;
beforeEach(() => {
PRODUCT_ESTABLISHMENT_MOCK = new ProductEstablishment(
new Product('121212', 'mock', 'mock.jpg', 1),
new Establishment(new Chain('mock', 'mock.png', 1), 'mock street', 1),
1,
);
PRODUCT_ESTABLISHMENT_QUERY_RESULT = {
address: PRODUCT_ESTABLISHMENT_MOCK.establishment.address,
barcode: PRODUCT_ESTABLISHMENT_MOCK.product.barcode,
chain_id: PRODUCT_ESTABLISHMENT_MOCK.establishment.chain.id!,
chain_image: PRODUCT_ESTABLISHMENT_MOCK.establishment.chain.image,
chain_name: PRODUCT_ESTABLISHMENT_MOCK.establishment.chain.name,
establishment_id: PRODUCT_ESTABLISHMENT_MOCK.establishment.id!,
id: PRODUCT_ESTABLISHMENT_MOCK.id!,
product_id: PRODUCT_ESTABLISHMENT_MOCK.product.id!,
product_image: PRODUCT_ESTABLISHMENT_MOCK.product.image,
product_name: PRODUCT_ESTABLISHMENT_MOCK.product.name,
};
sqlite = {
executeQuery: vi.fn().mockResolvedValue({ rows: [PRODUCT_ESTABLISHMENT_QUERY_RESULT] }),
};
TestBed.configureTestingModule({
providers: [{ provide: Sqlite, useValue: sqlite }],
});
service = TestBed.inject(ProductEstablishmentDAO);
});
it('should be created', () => {
expect(PRODUCT_ESTABLISHMENT_MOCK).toBeTruthy();
});
it('should receive mapped results on findAll', async () => {
const result = await service.findAll();
if (result.length > 0) {
expect(result[0]).toEqual(PRODUCT_ESTABLISHMENT_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should call executeQuery with object fields on insert', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
const expectedSql =
'INSERT INTO product_establishment ( product_id, establishment_id ) VALUES ( ?, ? );';
const expectedParams: any[] = [
PRODUCT_ESTABLISHMENT_MOCK.product.id,
PRODUCT_ESTABLISHMENT_MOCK.establishment.id,
];
PRODUCT_ESTABLISHMENT_MOCK.id = undefined;
await service.insert(PRODUCT_ESTABLISHMENT_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
describe('toDB', () => {
it('should throw if toDB is called with a product that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PRODUCT_ESTABLISHMENT_MOCK.product.id = undefined;
expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
});
it('should throw if toDB is called with a establishmen that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PRODUCT_ESTABLISHMENT_MOCK.establishment.id = undefined;
expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { Establishment } from '../models/Establishment';
import { Product } from '../models/Product';
import { ProductEstablishment } from '../models/ProductEstablisment';
import { Chain } from '../models/Chain';
import { DBProductEstablishment } from '../models/db/DBProductEstablishment';
import { ComposedDAO } from './ComposedDAO';
import { ProductEstablishmentQueryResult } from '../types/sqlite.type';
@Injectable({
providedIn: 'root',
})
export class ProductEstablishmentDAO extends ComposedDAO<
ProductEstablishment,
DBProductEstablishment,
ProductEstablishmentQueryResult
> {
constructor() {
super(
'product_establishment',
`
SELECT pe.id, pe.product_id, pe.establishment_id, p.barcode, p.image as product_image, p.name as product_name,
e.address, e.chain_id, c.image as chain_image, c.name as chain_name FROM product_establishment pe
JOIN product p ON p.id = pe.product_id
JOIN establishment e ON e.id = pe.establishment_id
JOIN chain c ON c.id = e.chain_id;`,
'pe',
);
}
protected override toDB(model: ProductEstablishment): DBProductEstablishment {
if (!model.product.id) throw new Error('Product id is required');
if (!model.establishment.id) throw new Error('Establishment id is required');
return new DBProductEstablishment(model.product.id, model.establishment.id, model.id);
}
protected override fromDB(qR: ProductEstablishmentQueryResult): ProductEstablishment {
const chain = new Chain(qR.chain_name, qR.chain_image, qR.chain_id);
const establishment = new Establishment(chain, qR.address, qR.establishment_id);
const product = new Product(qR.barcode, qR.product_name, qR.product_image, qR.product_id);
return new ProductEstablishment(product, establishment, qR.id);
}
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { PurchaseDAO } from './PurchaseDAO';
import { Sqlite } from '../services/sqlite';
import { Purchase } from '../models/Purchase';
import { Establishment } from '../models/Establishment';
import { Chain } from '../models/Chain';
import { Product } from '../models/Product';
import { PurchaseQueryResult } from '../types/sqlite.type';
describe('PurchaseDAO', () => {
let service: PurchaseDAO;
let sqlite: Partial<Sqlite>;
let PURCHASE_MOCK: Purchase;
let PURCHASE_QUERY_RESULT_MOCK: PurchaseQueryResult;
beforeEach(() => {
PURCHASE_MOCK = new Purchase(
new Establishment(new Chain('mock', 'mock.jpg', 1), 'mock street', 1),
new Product('1212112', 'mock', 'mock.png', 1),
1245.55,
3,
Date.now(),
1,
);
PURCHASE_QUERY_RESULT_MOCK = {
address: PURCHASE_MOCK.establishment.address,
barcode: PURCHASE_MOCK.product.barcode,
chain_id: PURCHASE_MOCK.establishment.chain.id!,
chain_image: PURCHASE_MOCK.establishment.chain.image,
chain_name: PURCHASE_MOCK.establishment.chain.name,
date: PURCHASE_MOCK.date,
establishment_id: PURCHASE_MOCK.establishment.id!,
id: PURCHASE_MOCK.id!,
price: PURCHASE_MOCK.price,
product_id: PURCHASE_MOCK.product.id!,
product_image: PURCHASE_MOCK.product.image,
product_name: PURCHASE_MOCK.product.name,
quantity: PURCHASE_MOCK.quantity,
};
sqlite = {
executeQuery: vi.fn().mockResolvedValue({ rows: [PURCHASE_QUERY_RESULT_MOCK] }),
};
TestBed.configureTestingModule({
providers: [{ provide: Sqlite, useValue: sqlite }],
});
service = TestBed.inject(PurchaseDAO);
});
it('should be created', () => {
expect(PURCHASE_MOCK).toBeTruthy();
});
it('should receive mapped results on findAll', async () => {
const result = await service.findAll();
if (result.length > 0) {
expect(result[0]).toEqual(PURCHASE_MOCK);
} else {
test.fails('Expected mock response to contain at least one value');
}
});
it('should call executeQuery with object fields on insert', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
const expectedSql =
'INSERT INTO purchase ( establishment_id, product_id, date, price, quantity ) VALUES ( ?, ?, ?, ?, ? );';
const expectedParams: any[] = [
PURCHASE_MOCK.establishment.id,
PURCHASE_MOCK.product.id,
PURCHASE_MOCK.date,
PURCHASE_MOCK.price * 100,
PURCHASE_MOCK.quantity,
];
PURCHASE_MOCK.id = undefined;
await service.insert(PURCHASE_MOCK);
expect(sqlite.executeQuery).toHaveBeenCalledExactlyOnceWith(expectedSql, expectedParams);
});
describe('toDB', () => {
it('should throw if toDB is called with a establishmen that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PURCHASE_MOCK.establishment.id = undefined;
expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
});
it('should throw if toDB is called with a product that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PURCHASE_MOCK.product.id = undefined;
expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { Purchase } from '../models/Purchase';
import { DBPurchase } from '../models/db/DBPurchase';
import { Establishment } from '../models/Establishment';
import { Chain } from '../models/Chain';
import { Product } from '../models/Product';
import { ComposedDAO } from './ComposedDAO';
import { PurchaseQueryResult } from '../types/sqlite.type';
@Injectable({
providedIn: 'root',
})
export class PurchaseDAO extends ComposedDAO<Purchase, DBPurchase, PurchaseQueryResult> {
constructor() {
super(
Purchase.name,
`SELECT p.id, p.price, p.quantity, p.date, p.establishment_id, p.product_id,
pr.barcode, pr.image as product_image, pr.name as product_name,
e.address, e.chain_id,
c.image as chain_image, c.name as chain_name
FROM purchase p
JOIN product pr ON pr.id = p.product_id
JOIN establishment e ON e.id = p.establishment_id
JOIN chain c ON c.id = e.chain_id;`,
'e',
);
}
protected override toDB(model: Purchase): DBPurchase {
if (!model.establishment.id) throw new Error('Esablishment id is required');
if (!model.product.id) throw new Error('Product id is required');
return new DBPurchase(
model.establishment.id,
model.product.id,
model.date,
model.price * 100,
model.quantity,
model.id,
);
}
protected override fromDB(qR: PurchaseQueryResult): Purchase {
const chain = new Chain(qR.chain_name, qR.chain_image, qR.chain_id);
const establishment = new Establishment(chain, qR.address, qR.establishment_id);
const product = new Product(qR.barcode, qR.product_name, qR.product_image, qR.product_id);
return new Purchase(establishment, product, qR.price / 100, qR.quantity, qR.date, qR.id);
}
}

View File

@@ -0,0 +1,7 @@
import { Chain } from './Chain';
describe('Chain', () => {
it('should create', () => {
expect(new Chain('mock', 'mock.jpg', 1)).toBeTruthy();
});
});

3
src/app/models/Chain.ts Normal file
View File

@@ -0,0 +1,3 @@
export class Chain {
constructor(public name: string | null, public image: string | null, public id?: number) {}
}

View File

@@ -0,0 +1,8 @@
import { Chain } from './Chain';
import { Establishment } from './Establishment';
describe('Establishment', () => {
it('should create', () => {
expect(new Establishment(new Chain('mock', 'mock.jpg', 1), 'Mock street', 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,5 @@
import { Chain } from './Chain';
export class Establishment {
constructor(public chain: Chain, public address = '', public id?: number) {}
}

View File

@@ -0,0 +1,7 @@
import { Product } from './Product';
describe('Product', () => {
it('should create', () => {
expect(new Product('1212121121', 'Mock Product', 'product.png', 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,8 @@
export class Product {
constructor(
public barcode: string,
public name = '',
public image: string | null = null,
public id?: number
) {}
}

View File

@@ -0,0 +1,16 @@
import { Chain } from './Chain';
import { Establishment } from './Establishment';
import { Product } from './Product';
import { ProductEstablishment } from './ProductEstablisment';
describe('ProductEstablishment', () => {
it('should create', () => {
expect(
new ProductEstablishment(
new Product('12121212', 'mock product', 'mock.jpg', 1),
new Establishment(new Chain('mock', 'mock', 1)),
1,
),
);
});
});

View File

@@ -0,0 +1,14 @@
import { Establishment } from './Establishment';
import { Product } from './Product';
export class ProductEstablishment {
id?: number;
establishment: Establishment;
product: Product;
constructor(product: Product, establishment: Establishment, id?: number) {
this.id = id;
this.establishment = establishment;
this.product = product;
}
}

View File

@@ -0,0 +1,25 @@
import { Chain } from './Chain';
import { Establishment } from './Establishment';
import { Product } from './Product';
import { Purchase } from './Purchase';
describe('Purchase', () => {
let purchase: Purchase;
beforeEach(() => {
purchase = new Purchase(
new Establishment(new Chain('mock', 'image_mock.png', 1), 'mock street', 1),
new Product('121212121221', 'mock product', 'image_mock.png', 1),
1254.51,
)
});
it('should create', () => {
expect(purchase).toBeTruthy();
});
it('should divide by 100 on price get and multiply by 100 on set', () => {
const PRICE_MOCK = 1475.56;
purchase.price = PRICE_MOCK;
expect(purchase.price).toBe(PRICE_MOCK);
});
});

View File

@@ -0,0 +1,24 @@
import { Establishment } from './Establishment';
import { Product } from './Product';
export class Purchase {
#price: number;
constructor(
public establishment: Establishment,
public product: Product,
price = 0,
public quantity = 1,
public date = Date.now(),
public id?: number
) {
this.#price = price * 100;
}
get price() {
return this.#price / 100;
}
set price(price: number) {
this.#price = price * 100;
}
}

View File

@@ -0,0 +1,7 @@
import { DBChain } from './DBChain';
describe('Chain', () => {
it('should create', () => {
expect(new DBChain('mock', 'mock.jpg', 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,3 @@
import { Chain } from '../Chain';
export class DBChain extends Chain {}

View File

@@ -0,0 +1,7 @@
import { DBEstablishment } from './DBEstablishment';
describe('DBEstablishment', () => {
it('should create', () => {
expect(new DBEstablishment('mock address', 1, 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,3 @@
export class DBEstablishment {
constructor(public address: string, public chain_id: number, public id?: number) {}
}

View File

@@ -0,0 +1,7 @@
import { DBProduct } from './DBProduct';
describe('DBProduct', () => {
it('should create', () => {
expect(new DBProduct('121212112', 'product mock', 'mock.jpg', 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,3 @@
import { Product } from '../Product';
export class DBProduct extends Product {}

View File

@@ -0,0 +1,7 @@
import { DBProductEstablishment } from './DBProductEstablishment';
describe('DBProductEstablishment', () => {
it('should create', () => {
expect(new DBProductEstablishment(1, 1, 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,3 @@
export class DBProductEstablishment {
constructor(public product_id: number, public establishment_id: number, public id?: number) {}
}

View File

@@ -0,0 +1,7 @@
import { DBPurchase } from './DBPurchase';
describe('DBPurchase', () => {
it('should create', () => {
expect(new DBPurchase(1, 1, Date.now(), 1000, 1, 1)).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
export class DBPurchase {
constructor(
public establishment_id: number,
public product_id: number,
public date: number,
public price = 0,
public quantity = 1,
public id?: number
) {}
}

View File

@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Sqlite } from './sqlite';
import { WebSqlite } from 'angular-web-sqlite';
import { BatchOp } from '../types/sqlite.type';
describe('Sqlite', () => {
let service: Sqlite;
let webSqlite: Partial<WebSqlite>;
beforeEach(() => {
webSqlite = {
init: vi.fn().mockResolvedValue(undefined),
executeSql: vi.fn().mockResolvedValue({ rows: [] }),
batchSql: vi.fn().mockResolvedValue({ rows: [] }),
};
TestBed.configureTestingModule({
providers: [{ provide: WebSqlite, useValue: webSqlite }],
});
service = TestBed.inject(Sqlite);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should init database', () => {
service.initializeDatabase('test').then(() => expect(webSqlite.init).toHaveBeenCalledTimes(1));
});
it('should call executeSql with the given parameters', () => {
const SQL_MOCK = `INSERT INTO MOCK VALUES ( ?, ? );`;
const PARAMS_MOCK = [1, 'MOCK'];
service
.executeQuery(SQL_MOCK, PARAMS_MOCK)
.then(() =>
expect(webSqlite.executeSql).toHaveBeenCalledExactlyOnceWith(SQL_MOCK, PARAMS_MOCK),
);
});
it('should call batchSql with the given parameters', () => {
const batch: BatchOp[] = [
['DROP TABLE chain;', []],
['DROP TABLE establishment;', []],
];
service
.batchSqlOperations(batch)
.then(() => expect(webSqlite.batchSql).toHaveBeenCalledExactlyOnceWith(batch));
});
it('should drop all existing tables that are not sqlite engine related', () => {
const batchSqlOperationsSpy = vi
.spyOn(service, 'batchSqlOperations')
.mockResolvedValue({ rows: [] });
const TABLES_QUERY_RESULT_MOCK = {
rows: [{ name: 'chain' }, { name: 'establishment' }, { name: 'sqlite_sequence' }],
};
const expectedBatch = TABLES_QUERY_RESULT_MOCK.rows
.filter((r) => !r.name.startsWith('sqlite_'))
.map((table) => [`DROP TABLE ${table.name};`, []]);
webSqlite.executeSql = vi.fn().mockResolvedValue(TABLES_QUERY_RESULT_MOCK);
service
.dropAllTables()
.then(() => expect(batchSqlOperationsSpy).toHaveBeenCalledExactlyOnceWith(expectedBatch));
});
});

View File

@@ -0,0 +1,34 @@
import { inject, Injectable } from '@angular/core';
import { WebSqlite } from 'angular-web-sqlite';
import { BatchOp } from '../types/sqlite.type';
@Injectable({
providedIn: 'root',
})
export class Sqlite {
private readonly webSqlite = inject(WebSqlite);
initializeDatabase(dbName: string) {
return this.webSqlite.init(dbName);
}
async executeQuery(sql: string, params: any[] = []) {
const result = await this.webSqlite.executeSql(sql, params);
return result;
}
async batchSqlOperations(ops: BatchOp[]) {
const result = await this.webSqlite.batchSql(ops);
return result;
}
async dropAllTables() {
const tables: { rows: { name: string }[] } = await this.executeQuery(
"SELECT * FROM sqlite_master WHERE type='table';"
);
const dropQueries: BatchOp[] = tables.rows
.filter((r) => !r.name.startsWith('sqlite_'))
.map((table) => [`DROP TABLE ${table.name};`, []]);
return this.batchSqlOperations(dropQueries);
}
}

22
src/app/types/globalThis.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
type ImageBitmapSource = HTMLImageElement | SVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas | VideoFrame | Blob | ImageData
export type BarcodeDetectorOptions = {
formats: string[]
}
export type DetectedBarcode = {
boundingBox: DOMRectReadOnly;
cornerPoints: coords[];
format: string[];
rawValue: string;
}
declare global {
var BarcodeDetector: BarcodeDetector;
}
declare type BarcodeDetector = {
new (formats?: {formats: string[]}): BarcodeDetector;
static getSupportedFormats(): Promise<string[]>;
detect(imageBitmapSource: ImageBitmapSource): Promise<DetectedBarcode[]>;
}

View File

@@ -0,0 +1,37 @@
import { Chain } from '../models/Chain';
import { Establishment } from '../models/Establishment';
export type QueryResult<T> = { rows: T[] };
export type BatchOp = [string, any[]];
export type PurchaseQueryResult = {
id: number;
price: number;
quantity: number;
date: number;
establishment_id: number;
product_id: number;
barcode: string;
product_image: string | null;
product_name: string;
address: string;
chain_id: number;
chain_image: string | null;
chain_name: string | null;
};
export type ProductEstablishmentQueryResult = {
address: string;
barcode: string;
chain_id: number;
chain_image: string | null;
chain_name: string | null;
establishment_id: number;
id: number;
product_id: number;
product_image: string | null;
product_name: string;
};
export type EstablishmentQueryResult = Omit<Establishment, 'chain'> &
Omit<Chain, 'id'> & { id: number; chain_id: number };

View File

@@ -0,0 +1,198 @@
import type { BarcodeDetector, DetectedBarcode } from '../types/globalThis';
export class BarcodeReader extends HTMLElement {
animationFrameId = null;
constraints = {
audio: false,
video: {
facingMode: 'environment',
},
};
container = document.createElement('div');
video = document.createElement('video');
dataCanvas = document.createElement('canvas');
dataCtx: CanvasRenderingContext2D | null | undefined;
detector: BarcodeDetector | null = null;
code: DetectedBarcode | null = null;
stream: MediaStream | null = null;
scanId = -1;
frame = document.createElement('div');
constructor() {
super();
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/barcode-reader/barcode-reader.css';
this.container.setAttribute('id', 'container');
const closeBtn = document.createElement('button');
closeBtn.textContent = 'X';
closeBtn.onclick = this.stopScan.bind(this);
closeBtn.classList.add('close-btn')
this.container.appendChild(closeBtn);
const videoContainer = document.createElement('div');
videoContainer.setAttribute('id', 'video-container');
this.container.appendChild(videoContainer);
const overlay = document.createElement('div');
overlay.classList.add('overlay');
this.video.setAttribute('autoplay', '');
this.video.setAttribute('playsinline', '');
this.dataCanvas.setAttribute('id', 'data-canvas');
this.frame.setAttribute('id', 'frame');
this.frame.style.display = 'none';
for (let el of ['top-left', 'top-right', 'bottom-right', 'bottom-left']) {
const div = document.createElement('div');
div.classList.add('corner', el);
this.frame.appendChild(div);
}
videoContainer.appendChild(overlay);
videoContainer.appendChild(this.dataCanvas);
videoContainer.appendChild(this.video);
videoContainer.appendChild(this.frame);
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(link);
shadowRoot.appendChild(this.container);
this.capturing = false;
this.dataCtx = this.dataCanvas.getContext('2d', { willReadFrequently: true, alpha: false });
}
get capturing() {
return this.hasAttribute('capturing');
}
set capturing(isCapturing) {
if (isCapturing) {
this.setAttribute('capturing', '');
} else {
this.removeAttribute('capturing');
}
}
async connectedCallback() {
this.detector = await this.getDetector();
this.video.onplaying = async () => {
if (this.dataCanvas) {
this.dataCanvas.width = this.dataCanvas.offsetWidth;
this.dataCanvas.height = this.dataCanvas.offsetHeight;
this.getResults();
}
};
}
async getDetector(): Promise<BarcodeDetector | null> {
if (globalThis.BarcodeDetector) {
const formats = await BarcodeDetector.getSupportedFormats();
return new BarcodeDetector({ formats });
}
return null;
}
async scan() {
this.showContainer(true);
try {
this.capturing = true;
this.code = null;
if (this.stream instanceof MediaStream) {
await this.getResults();
} else {
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'loading' } }));
this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
this.startVideo();
this.showFrame();
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'started' } }));
this.dispatchEvent(new CustomEvent('scan-start'));
}
} catch (e) {
console.log(e);
this.showContainer(false);
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'stopped' } }));
this.dispatchEvent(new CustomEvent('scan-permission-denied'));
this.capturing = false;
}
}
stopScan() {
clearTimeout(this.scanId);
if (this.stream) {
this.capturing = false;
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
this.video.srcObject = null;
this.dispatchEvent(new CustomEvent('scan-stop'));
this.dispatchEvent(new CustomEvent('scan-status', { detail: { state: 'stopped' } }));
this.hideFrame();
this.showContainer(false);
}
}
startVideo() {
if (this.video) this.video.srcObject = this.stream;
}
parseResults(results: DetectedBarcode[]) {
const code = results.length > 0 ? results[0] : undefined;
if (code && code.rawValue !== '') {
clearTimeout(this.scanId);
this.code = code;
this.capturing = false;
setTimeout(() => {
this.dispatchEvent(
new CustomEvent('result', {
detail: { code },
})
);
this.stopScan();
}, 300);
} else {
this.scanId = setTimeout(this.getResults.bind(this), 300);
}
}
async getResults() {
if (this.code) {
clearTimeout(this.scanId);
return false;
}
try {
if (this.video && this.detector) {
let imgData: ImageData | null = this.getImageData(this.video);
if (imgData) {
const results = await this.detector.detect(imgData);
imgData = null;
this.parseResults(results);
}
}
} catch (e) {
console.log('error', e);
}
return undefined;
}
getImageData(source: HTMLVideoElement) {
if (this.dataCtx) {
this.dataCtx.drawImage(source, 0, 0, this.dataCanvas.width, this.dataCanvas.height);
return this.dataCtx.getImageData(0, 0, this.dataCanvas.width, this.dataCanvas.height);
}
return null;
}
showContainer(show: boolean) {
this.container.style.display = show ? 'block' : 'none';
}
showFrame() {
this.frame.style.display = 'block';
}
hideFrame() {
this.frame.style.display = 'none';
}
}

View File

@@ -8,6 +8,13 @@
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<scan-loading-screen></scan-loading-screen>
<app-root></app-root>
<script src="/scan-loading-screen/scan-loading-screen.js" fetchpriority="high"></script>
<script>
document.addEventListener('ng-boot', () => {
document.querySelector('scan-loading-screen').remove();
})
</script>
</body>
</html>

View File

@@ -1,6 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { BarcodeReader } from './app/web-components/barcode-reader';
bootstrapApplication(App, appConfig)
.then(() => customElements.define('barcode-reader', BarcodeReader))
.catch((err) => console.error(err));

View File

@@ -0,0 +1,56 @@
const chainSql =
'CREATE TABLE IF NOT EXISTS chain ( ' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'image TEXT, ' +
'name TEXT ' +
');';
const establishmentSql =
'CREATE TABLE IF NOT EXISTS establishment ( ' +
'id INTEGER PRIMARY KEY AUTOINCREMENT ,' +
'address TEXT NOT NULL, ' +
'chain_id INTEGER NOT NULL, ' +
'FOREIGN KEY (chain_id) REFERENCES chain(id) ON DELETE CASCADE ' +
');';
const establishmentChainIdxSql =
'CREATE INDEX IF NOT EXISTS idx_establishment_chain_id ON establishment(chain_id);';
const productSql =
'CREATE TABLE IF NOT EXISTS product ( ' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'barcode TEXT NOT NULL UNIQUE, ' +
'name TEXT NOT NULL DEFAULT "", ' +
'image TEXT ' +
');';
const productEstablishmentSql =
'CREATE TABLE IF NOT EXISTS product_establishment ( ' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'product_id INTEGER NOT NULL, ' +
'establishment_id INTEGER NOT NULL, ' +
'FOREIGN KEY (product_id) REFERENCES product(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (establishment_id) REFERENCES establishment(id) ON DELETE CASCADE, ' +
'UNIQUE(product_id, establishment_id) ' +
');';
const purchaseSql =
'CREATE TABLE IF NOT EXISTS purchase ( ' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'establishment_id INTEGER NOT NULL, ' +
'product_id INTEGER NOT NULL, ' +
'price INTEGER NOT NULL DEFAULT 0, ' +
'quantity INTEGER NOT NULL DEFAULT 1, ' +
'date INTEGER NOT NULL, ' +
'FOREIGN KEY (establishment_id) REFERENCES establishment(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (product_id) REFERENCES product(id) ON DELETE CASCADE ' +
');';
export const tables: [string, any[]][] = [
[chainSql, []],
[establishmentSql, []],
[establishmentChainIdxSql, []],
[productSql, []],
[productEstablishmentSql, []],
[purchaseSql, []],
];

View File

@@ -1 +1,4 @@
/* You can add global styles to this file, and also import other style files */
html, body {
margin: 0 0;
height: 100%;
}