diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7395ee0..d3a6db9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -29,4 +29,25 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + flags: unit + + integration: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Integration Tests + run: go test -tags integration -v -timeout 10m -coverprofile=coverage.out -covermode=atomic ./internal/utils/ ./internal/db/ ./internal/db/repository/ + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: integration diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..97d3e65 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,11 @@ +# go.yml uploads coverage twice per commit: the "unit" job runs go test ./... +# (no build tag) and the "integration" job runs go test -tags integration on the +# DB-dependent packages (internal/utils, internal/db, internal/db/repository). +# Without this, Codecov posts a status as soon as either upload lands - since +# the unit job usually finishes first and barely touches DB-dependent code, +# PRs would briefly show a failing patch-coverage check that flips to passing +# once the integration job's upload arrives a bit later. +# after_n_builds waits for both before posting a final status. +codecov: + notify: + after_n_builds: 2 diff --git a/go.mod b/go.mod index d6d3a52..b2a50ad 100644 --- a/go.mod +++ b/go.mod @@ -6,47 +6,96 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 + github.com/testcontainers/testcontainers-go v0.42.0 github.com/xuri/excelize/v2 v2.10.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/tiendc/go-deepcopy v1.7.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5b53fa8..d75a2fc 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,59 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -25,9 +66,12 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -40,20 +84,54 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= @@ -63,9 +141,17 @@ github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7 github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -73,8 +159,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -85,30 +177,58 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/api/handler/calculation_test.go b/internal/api/handler/calculation_test.go index 1436db0..079b6c1 100644 --- a/internal/api/handler/calculation_test.go +++ b/internal/api/handler/calculation_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -96,15 +97,15 @@ func realisticBuilding() *models.TabulaBuildingParameters { BasicParameters: &models.BasicParameters{ BuildingAppearance: &models.BuildingThematic{N_Storey: 2}, Envelope: &models.Envelope{ - A_C_Ref_Input: 150, - A_C_IntDim: 150, - V_C: 375, - A_Roof_1: 75, - A_Wall_1: 120, - A_Floor_1: 75, - A_Window_1: 20, + A_C_Ref_Input: 150, + A_C_IntDim: 150, + V_C: 375, + A_Roof_1: 75, + A_Wall_1: 120, + A_Floor_1: 75, + A_Window_1: 20, A_Window_South: 12, - A_Door_1: 2, + A_Door_1: 2, }, }, AdvancedParameters: &models.AdvancedParameters{ @@ -113,13 +114,13 @@ func realisticBuilding() *models.TabulaBuildingParameters { Uvalues: &models.Uvalues{ U_Roof_1: 0.5, U_Wall_1: 0.8, U_Floor_1: 0.6, U_Window_1: 2.0, U_Door_1: 2.0, }, - Insulation: &models.InsulationThicknesses{}, - SolarGains: &models.SolarGains{I_Sol_South: 500, I_Sol_Horizontal: 800}, - ThermalBridges: &models.ThermalBridgeParameters{}, - HeatLosses: &models.TransmissionHeatLoss{}, - ThermalResistances: &models.ThermalResistances{}, - InsulationMeasures: &models.InsulationPredefinedMeasures{}, - ActualInsulation: &models.ActualInsulationThicknesses{}, + Insulation: &models.InsulationThicknesses{}, + SolarGains: &models.SolarGains{I_Sol_South: 500, I_Sol_Horizontal: 800}, + ThermalBridges: &models.ThermalBridgeParameters{}, + HeatLosses: &models.TransmissionHeatLoss{}, + ThermalResistances: &models.ThermalResistances{}, + InsulationMeasures: &models.InsulationPredefinedMeasures{}, + ActualInsulation: &models.ActualInsulationThicknesses{}, HeatTransfer: &models.HeatTransferCoefficients{ Phi_int: 4.0, F_sh_hor: 0.9, F_sh_vert: 0.9, F_f: 0.7, F_w: 0.9, C_m: 165000, }, @@ -207,6 +208,48 @@ func TestCalculateHeatDemand_zeroBuildingProducesNaN_returns500(t *testing.T) { } } +func TestCalculateHeatDemand_repoError_returns500(t *testing.T) { + mock := &mockRepo{ + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return nil, "", 0, errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodPost, "/calculate/DE.N.SFH.01.Gen", "/calculate/:code", h.CalculateHeatDemand, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestCalculateHeatDemand_malformedJSONBody_returns400(t *testing.T) { + mock := &mockRepo{ + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return minimalBuilding(), "DE.N.SFH.01.Gen", 100.0, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodPost, "/calculate/DE.N.SFH.01.Gen", "/calculate/:code", h.CalculateHeatDemand, []byte("not json")) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for malformed JSON body, got %d", w.Code) + } +} + +func TestCalculateHeatDemand_pipelinePanics_returns500(t *testing.T) { + mock := &mockRepo{ + // A nil building triggers a nil-pointer panic inside the pipeline, + // which the pipeline itself converts into an error - exercises the + // handler's "Pipeline execution failed" branch. + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return nil, "DE.N.SFH.01.Gen", 100.0, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodPost, "/calculate/DE.N.SFH.01.Gen", "/calculate/:code", h.CalculateHeatDemand, []byte("{}")) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d — body: %s", w.Code, w.Body.String()) + } +} + func TestCalculateHeatDemand_success_returns200withExpectedShape(t *testing.T) { mock := &mockRepo{ getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { diff --git a/internal/api/handler/data_test.go b/internal/api/handler/data_test.go new file mode 100644 index 0000000..36c7a37 --- /dev/null +++ b/internal/api/handler/data_test.go @@ -0,0 +1,121 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/thd-spatial-ai/ignis/internal/db/repository" + "github.com/thd-spatial-ai/ignis/internal/models" +) + +// --- GetVariants --- + +func TestGetVariants_success_returns200withVariants(t *testing.T) { + mock := &mockRepo{ + listVariants: func(_ context.Context, _ string) ([]string, error) { + return []string{"DE.N.SFH.01.Gen", "DE.N.SFH.02.Gen"}, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE", "/variants/:country_iso2", h.GetVariants, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Country string `json:"country"` + Data []string `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if resp.Country != "germany" { + t.Errorf("country = %q, want %q", resp.Country, "germany") + } + if len(resp.Data) != 2 { + t.Errorf("expected 2 variants, got %d", len(resp.Data)) + } +} + +func TestGetVariants_repoError_returns500(t *testing.T) { + mock := &mockRepo{ + listVariants: func(_ context.Context, _ string) ([]string, error) { + return nil, errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE", "/variants/:country_iso2", h.GetVariants, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- GetVariantData --- + +func TestGetVariantData_malformedCode_returns400(t *testing.T) { + h := newTestHandler(&mockRepo{}) + w := serve(http.MethodGet, "/data/DE", "/data/:code", h.GetVariantData, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestGetVariantData_unknownCountry_returns400(t *testing.T) { + h := newTestHandler(&mockRepo{}) + w := serve(http.MethodGet, "/data/ZZ.N.SFH.01.Gen", "/data/:code", h.GetVariantData, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestGetVariantData_variantNotFound_returns404(t *testing.T) { + mock := &mockRepo{ + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return nil, "", 0, repository.ErrVariantNotFound + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/data/DE.N.SFH.01.Gen", "/data/:code", h.GetVariantData, nil) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestGetVariantData_repoError_returns500(t *testing.T) { + mock := &mockRepo{ + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return nil, "", 0, errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/data/DE.N.SFH.01.Gen", "/data/:code", h.GetVariantData, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestGetVariantData_success_returns200(t *testing.T) { + mock := &mockRepo{ + getVariant: func(_ context.Context, _, _ string) (*models.TabulaBuildingParameters, string, float64, error) { + return realisticBuilding(), "DE.N.SFH.01.Gen", 123.4, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/data/DE.N.SFH.01.Gen", "/data/:code", h.GetVariantData, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + for _, key := range []string{"country", "variant_code", "tabula_data", "expected_q_h_nd"} { + if _, ok := resp[key]; !ok { + t.Errorf("response missing field %q", key) + } + } +} diff --git a/internal/api/handler/handler_helpers_test.go b/internal/api/handler/handler_helpers_test.go index 9e61b65..b01d9c7 100644 --- a/internal/api/handler/handler_helpers_test.go +++ b/internal/api/handler/handler_helpers_test.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "errors" "net/http" "strings" "testing" @@ -123,9 +124,9 @@ func TestRefurbishmentLabel_beyondKnownPositions(t *testing.T) { func TestMatchVariants_missingParams_returns400(t *testing.T) { h := newTestHandler(&mockRepo{}) cases := []struct{ path, route string }{ - {"/variants/DE/match", "/variants/:country_iso2/match"}, // no type or period - {"/variants/DE/match?type=SFH", "/variants/:country_iso2/match"}, // missing period - {"/variants/DE/match?period=01", "/variants/:country_iso2/match"}, // missing type + {"/variants/DE/match", "/variants/:country_iso2/match"}, // no type or period + {"/variants/DE/match?type=SFH", "/variants/:country_iso2/match"}, // missing period + {"/variants/DE/match?period=01", "/variants/:country_iso2/match"}, // missing type } for _, tc := range cases { w := serve(http.MethodGet, tc.path, tc.route, h.MatchVariants, nil) @@ -175,6 +176,19 @@ func TestMatchVariants_returnsLabelledVariants(t *testing.T) { } } +func TestMatchVariants_repoError_returns500(t *testing.T) { + mock := &mockRepo{ + matchVariants: func(_ context.Context, _, _ string) ([]string, error) { + return nil, errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&period=01", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + func TestMatchVariants_emptyResult_returns200withEmptyList(t *testing.T) { mock := &mockRepo{ matchVariants: func(_ context.Context, _, _ string) ([]string, error) { @@ -197,3 +211,10 @@ func TestMatchVariants_emptyResult_returns200withEmptyList(t *testing.T) { t.Errorf("expected empty data list, got %d entries", len(resp.Data)) } } + +func TestNew_constructsHandlerWithRepo(t *testing.T) { + h := New(nil, "tabula") + if h == nil || h.repo == nil { + t.Fatal("expected New to return a Handler with a non-nil repo") + } +} diff --git a/internal/api/middleware/middleware_test.go b/internal/api/middleware/middleware_test.go new file mode 100644 index 0000000..5622c11 --- /dev/null +++ b/internal/api/middleware/middleware_test.go @@ -0,0 +1,138 @@ +package middleware + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newTestEngine(handlers ...gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(handlers...) + r.POST("/echo", func(c *gin.Context) { + body := make([]byte, 0) + buf := make([]byte, 512) + for { + n, err := c.Request.Body.Read(buf) + body = append(body, buf[:n]...) + if err != nil { + break + } + } + c.String(http.StatusOK, "%d", len(body)) + }) + r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) + return r +} + +func TestRequestBodyLimit_allowsSmallBody(t *testing.T) { + r := newTestEngine(RequestBodyLimit()) + body := bytes.Repeat([]byte("a"), 100) + req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestRequestBodyLimit_rejectsOversizedBody(t *testing.T) { + r := newTestEngine(RequestBodyLimit()) + body := bytes.Repeat([]byte("a"), maxRequestBodyBytes+1) + req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge) + } +} + +func TestRequestLogger_passesThroughAndLogsStatus(t *testing.T) { + r := newTestEngine(RequestLogger()) + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "pong" { + t.Errorf("body = %q, want %q", w.Body.String(), "pong") + } +} + +func TestCORS_allowedOrigin(t *testing.T) { + t.Setenv("ALLOWED_ORIGINS", "http://localhost:5173,https://app.example.com") + r := newTestEngine(CORS()) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "http://localhost:5173") + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestCORS_disallowedOrigin(t *testing.T) { + t.Setenv("ALLOWED_ORIGINS", "https://app.example.com") + r := newTestEngine(CORS()) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("Origin", "https://evil.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for disallowed origin", got) + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d (request still proceeds, just without CORS headers)", w.Code, http.StatusOK) + } +} + +func TestCORS_noOriginUnsetEnv(t *testing.T) { + t.Setenv("ALLOWED_ORIGINS", "") + r := newTestEngine(CORS()) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty", got) + } +} + +func TestCORS_optionsPreflight_isAborted(t *testing.T) { + t.Setenv("ALLOWED_ORIGINS", "https://app.example.com") + r := newTestEngine(CORS()) + + req := httptest.NewRequest(http.MethodOptions, "/ping", nil) + req.Header.Set("Origin", "https://app.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent) + } + if strings.TrimSpace(w.Body.String()) != "" { + t.Errorf("body = %q, want empty (handler must not run after preflight abort)", w.Body.String()) + } +} diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go new file mode 100644 index 0000000..84a6d77 --- /dev/null +++ b/internal/api/router/router_test.go @@ -0,0 +1,76 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/thd-spatial-ai/ignis/internal/api/handler" + + "github.com/gin-gonic/gin" +) + +func TestHealthCheck(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/health", HealthCheck) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != `{"status":"OK"}` { + t.Errorf("body = %q, want %q", w.Body.String(), `{"status":"OK"}`) + } +} + +func TestRegisterRoutes_registersExpectedRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + // handler.New stores the pool without dialing it, so a nil pool is safe + // here - route registration never issues a query. + h := handler.New(nil, "tabula") + + RegisterRoutes(r, h) + + want := map[string]bool{ + "GET /favicon.ico": false, + "GET /health": false, + "GET /api/v1/data/:code": false, + "GET /api/v1/variants/:country_iso2": false, + "GET /api/v1/variants/:country_iso2/match": false, + "GET /api/v1/fields": false, + "POST /api/v1/calculate/:code": false, + } + + for _, route := range r.Routes() { + key := route.Method + " " + route.Path + if _, expected := want[key]; expected { + want[key] = true + } + } + + for route, found := range want { + if !found { + t.Errorf("expected route %q to be registered", route) + } + } +} + +func TestRegisterRoutes_faviconReturnsNoContent(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + h := handler.New(nil, "tabula") + RegisterRoutes(r, h) + + req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ea3277f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadAppConfig_default(t *testing.T) { + t.Setenv("APP_PORT", "") + cfg := loadAppConfig() + if cfg.Port != defaultPort { + t.Errorf("Port = %q, want default %q", cfg.Port, defaultPort) + } +} + +func TestLoadAppConfig_fromEnv(t *testing.T) { + t.Setenv("APP_PORT", "9090") + cfg := loadAppConfig() + if cfg.Port != "9090" { + t.Errorf("Port = %q, want %q", cfg.Port, "9090") + } +} + +func TestLoadDBConfig_missingPassword_panics(t *testing.T) { + t.Setenv("DB_PASSWORD", "") + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic when DB_PASSWORD is unset, got none") + } + }() + loadDBConfig() +} + +func TestLoadDBConfig_populatesFromEnv(t *testing.T) { + t.Setenv("DB_PASSWORD", "secret") + t.Setenv("DB_HOST", "db.internal") + t.Setenv("DB_PORT", "5433") + t.Setenv("DB_NAME", "ignis_test") + t.Setenv("DB_USER", "tester") + t.Setenv("DB_SSL_MODE", "disable") + + cfg := loadDBConfig() + + if cfg.Host != "db.internal" || cfg.Port != "5433" || cfg.Name != "ignis_test" || + cfg.User != "tester" || cfg.Password != "secret" || cfg.SSLMode != "disable" { + t.Errorf("unexpected DBConfig: %+v", cfg) + } + if cfg.Schemas == nil || cfg.Schemas.Tabula != tabulaSchema { + t.Errorf("Schemas.Tabula = %+v, want %q", cfg.Schemas, tabulaSchema) + } +} + +func TestLoadDBConfig_defaults(t *testing.T) { + t.Setenv("DB_PASSWORD", "secret") + t.Setenv("DB_HOST", "") + t.Setenv("DB_PORT", "") + t.Setenv("DB_NAME", "") + t.Setenv("DB_USER", "") + t.Setenv("DB_SSL_MODE", "") + + cfg := loadDBConfig() + + if cfg.Host != "localhost" || cfg.Port != "5432" || cfg.Name != "ignis" || + cfg.User != "postgres" || cfg.SSLMode != "require" { + t.Errorf("unexpected defaults: %+v", cfg) + } +} + +func TestLoadSchemas(t *testing.T) { + s := loadSchemas() + if s.Tabula != tabulaSchema { + t.Errorf("Tabula = %q, want %q", s.Tabula, tabulaSchema) + } +} + +func TestLoadDataPaths_fallbackWhenNoXlsx(t *testing.T) { + dir := t.TempDir() + restore := chdir(t, dir) + defer restore() + + paths := loadDataPaths() + want := dataDir + "tabula-calculator.xlsx" + if paths.ExcelFile != want { + t.Errorf("ExcelFile = %q, want fallback %q", paths.ExcelFile, want) + } +} + +func TestLoadDataPaths_findsXlsxFile(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "data"), 0755); err != nil { + t.Fatal(err) + } + xlsxPath := filepath.Join(dir, "data", "my-workbook.xlsx") + if err := os.WriteFile(xlsxPath, []byte("not a real workbook"), 0644); err != nil { + t.Fatal(err) + } + + restore := chdir(t, dir) + defer restore() + + paths := loadDataPaths() + want := dataDir + "my-workbook.xlsx" + if paths.ExcelFile != want { + t.Errorf("ExcelFile = %q, want %q", paths.ExcelFile, want) + } +} + +func TestLoadConfig(t *testing.T) { + t.Setenv("DB_PASSWORD", "secret") + cfg := LoadConfig() + if cfg.App == nil || cfg.DB == nil || cfg.Data == nil { + t.Errorf("LoadConfig returned incomplete config: %+v", cfg) + } +} + +// chdir switches the working directory for the duration of a test and +// returns a function that restores the original directory. +func chdir(t *testing.T, dir string) func() { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + return func() { + if err := os.Chdir(orig); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..0a467ee --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,45 @@ +package config + +import "testing" + +func TestLoadEnv_doesNotPanicWithoutDotenvFile(t *testing.T) { + // No .env file exists in internal/config/ - godotenv.Load() returns an + // error that LoadEnv deliberately swallows so the app can run purely off + // real environment variables (e.g. in Docker). + LoadEnv() +} + +func TestGetEnv_returnsValueWhenSet(t *testing.T) { + t.Setenv("IGNIS_TEST_KEY", "value") + if got := GetEnv("IGNIS_TEST_KEY", "fallback"); got != "value" { + t.Errorf("GetEnv = %q, want %q", got, "value") + } +} + +func TestGetEnv_returnsFallbackWhenUnset(t *testing.T) { + t.Setenv("IGNIS_TEST_KEY", "") + if got := GetEnv("IGNIS_TEST_KEY", "fallback"); got != "fallback" { + t.Errorf("GetEnv = %q, want %q", got, "fallback") + } +} + +func TestGetEnvAsInt_returnsParsedValue(t *testing.T) { + t.Setenv("IGNIS_TEST_INT", "42") + if got := GetEnvAsInt("IGNIS_TEST_INT", 7); got != 42 { + t.Errorf("GetEnvAsInt = %d, want %d", got, 42) + } +} + +func TestGetEnvAsInt_returnsFallbackWhenUnset(t *testing.T) { + t.Setenv("IGNIS_TEST_INT", "") + if got := GetEnvAsInt("IGNIS_TEST_INT", 7); got != 7 { + t.Errorf("GetEnvAsInt = %d, want %d", got, 7) + } +} + +func TestGetEnvAsInt_returnsFallbackWhenUnparseable(t *testing.T) { + t.Setenv("IGNIS_TEST_INT", "not-a-number") + if got := GetEnvAsInt("IGNIS_TEST_INT", 7); got != 7 { + t.Errorf("GetEnvAsInt = %d, want %d", got, 7) + } +} diff --git a/internal/db/repository/building_repository_test.go b/internal/db/repository/building_repository_test.go new file mode 100644 index 0000000..5f5c34f --- /dev/null +++ b/internal/db/repository/building_repository_test.go @@ -0,0 +1,222 @@ +package repository + +import ( + "reflect" + "testing" +) + +func TestBuildingRepository_qualifyTable(t *testing.T) { + cases := []struct { + schema, table, want string + }{ + {"tabula", "germany", `"tabula"."germany"`}, + {"", "germany", `"germany"`}, + } + for _, tc := range cases { + r := NewBuildingRepository(nil, tc.schema) + if got := r.qualifyTable(tc.table); got != tc.want { + t.Errorf("qualifyTable(schema=%q, table=%q) = %q, want %q", tc.schema, tc.table, got, tc.want) + } + } +} + +func TestInitializeTabulaData(t *testing.T) { + data := initializeTabulaData() + + if data.BasicParameters == nil || data.BasicParameters.BuildingAppearance == nil || data.BasicParameters.Envelope == nil { + t.Fatal("expected BasicParameters and its nested pointers to be non-nil") + } + if data.AdvancedParameters == nil { + t.Fatal("expected AdvancedParameters to be non-nil") + } + if data.AdvancedParameters.PredefinedCodes.F_Corr_CeilingHeight != 1.0 { + t.Errorf("F_Corr_CeilingHeight = %v, want 1.0 default", data.AdvancedParameters.PredefinedCodes.F_Corr_CeilingHeight) + } +} + +func TestPopulateStructFromMap_setsFieldsByJSONTag(t *testing.T) { + data := initializeTabulaData() + dataMap := map[string]interface{}{ + "Code_BuildingVariant": "DE.N.SFH.01.Gen", + "n_Storey": int32(3), + "A_Roof_1": float32(45.5), + "HeatingDays": int64(180), + "Theta_e": -12, + "n_air_infiltration": float64(0.5), + } + + populateStructFromMap(data, dataMap) + + if data.BasicParameters.BuildingAppearance.Code_BuildingVariant != "DE.N.SFH.01.Gen" { + t.Errorf("Code_BuildingVariant = %q, want %q", data.BasicParameters.BuildingAppearance.Code_BuildingVariant, "DE.N.SFH.01.Gen") + } + if data.BasicParameters.BuildingAppearance.N_Storey != 3 { + t.Errorf("N_Storey = %d, want 3 (from int32 source)", data.BasicParameters.BuildingAppearance.N_Storey) + } + if data.BasicParameters.Envelope.A_Roof_1 != float64(float32(45.5)) { + t.Errorf("A_Roof_1 = %v, want %v (from float32 source)", data.BasicParameters.Envelope.A_Roof_1, float64(float32(45.5))) + } + if data.AdvancedParameters.ClimateConditions.HeatingDays != 180 { + t.Errorf("HeatingDays = %d, want 180 (from int64 source)", data.AdvancedParameters.ClimateConditions.HeatingDays) + } + if data.AdvancedParameters.ClimateConditions.Theta_e != -12 { + t.Errorf("Theta_e = %v, want -12 (from int source, stringified fallback not expected)", data.AdvancedParameters.ClimateConditions.Theta_e) + } + if data.AdvancedParameters.AirInfiltration.N_air_infiltration != 0.5 { + t.Errorf("N_air_infiltration = %v, want 0.5", data.AdvancedParameters.AirInfiltration.N_air_infiltration) + } +} + +func TestPopulateStructFromMap_missingAndNilKeysAreSkipped(t *testing.T) { + data := initializeTabulaData() + dataMap := map[string]interface{}{ + "Code_BuildingVariant": nil, // present but nil - must be skipped, not zeroed weirdly + // "n_Storey" intentionally absent + } + + populateStructFromMap(data, dataMap) + + if data.BasicParameters.BuildingAppearance.Code_BuildingVariant != "" { + t.Errorf("Code_BuildingVariant = %q, want zero value when map value is nil", data.BasicParameters.BuildingAppearance.Code_BuildingVariant) + } + if data.BasicParameters.BuildingAppearance.N_Storey != 0 { + t.Errorf("N_Storey = %d, want zero value when key is absent", data.BasicParameters.BuildingAppearance.N_Storey) + } +} + +func TestPopulateStructFromMap_stringFieldNonStringFallback(t *testing.T) { + data := initializeTabulaData() + // Code_BuildingVariant is a string field; feeding it a non-string value + // exercises setFieldValue's fmt.Sprintf fallback branch. + dataMap := map[string]interface{}{"Code_BuildingVariant": 12345} + + populateStructFromMap(data, dataMap) + + if data.BasicParameters.BuildingAppearance.Code_BuildingVariant != "12345" { + t.Errorf("Code_BuildingVariant = %q, want %q (stringified fallback)", data.BasicParameters.BuildingAppearance.Code_BuildingVariant, "12345") + } +} + +func TestPopulateStructFromMap_nilPointerTarget(t *testing.T) { + // populateStruct must not panic when reflect.ValueOf(target) is a nil pointer. + var data *struct{ Foo string } + populateStructFromMap(data, map[string]interface{}{"Foo": "bar"}) +} + +func TestPopulateStructFromMap_nonStructTarget(t *testing.T) { + // populateStruct must be a no-op (not panic) for a non-struct, non-pointer target. + x := 5 + populateStructFromMap(&x, map[string]interface{}{"x": 10}) + if x != 5 { + t.Errorf("x = %d, want unchanged 5", x) + } +} + +func TestPopulateStructFromMap_unexportedFieldSkipped(t *testing.T) { + type withUnexported struct { + unexported string //nolint:unused // deliberately unexported to hit the CanSet()==false skip branch + Exported string `json:"exported"` + } + target := &withUnexported{} + populateStructFromMap(target, map[string]interface{}{ + "exported": "should be set", + }) + if target.Exported != "should be set" { + t.Errorf("Exported = %q, want %q", target.Exported, "should be set") + } + if target.unexported != "" { + t.Errorf("unexported = %q, want zero value (field.CanSet() must be false)", target.unexported) + } +} + +func TestPopulateStructFromMap_valueStructWithTag(t *testing.T) { + // Covers the "has a JSON tag, but is itself a nested value struct" branch + // in populateStruct - the tag is present but ignored in favour of recursing. + type Inner struct { + Name string `json:"name"` + } + type Outer struct { + Inner Inner `json:"inner"` + } + target := &Outer{} + populateStructFromMap(target, map[string]interface{}{"name": "nested"}) + if target.Inner.Name != "nested" { + t.Errorf("Inner.Name = %q, want %q", target.Inner.Name, "nested") + } +} + +func TestSetFieldValue_intFieldFromPlainInt(t *testing.T) { + type s struct { + N int `json:"n"` + } + target := &s{} + populateStructFromMap(target, map[string]interface{}{"n": 7}) + if target.N != 7 { + t.Errorf("N = %d, want 7", target.N) + } +} + +func TestSetFieldValue_floatFieldFromInt64(t *testing.T) { + type s struct { + F float64 `json:"f"` + } + target := &s{} + populateStructFromMap(target, map[string]interface{}{"f": int64(9)}) + if target.F != 9 { + t.Errorf("F = %v, want 9", target.F) + } +} + +func TestPopulateStructFromMap_valueStructWithoutTag(t *testing.T) { + // Covers the "no JSON tag, non-pointer nested struct" branch in populateStruct. + type Inner struct { + Name string `json:"name"` + } + type Outer struct { + Inner Inner // no json tag, embedded-by-value struct + } + target := &Outer{} + populateStructFromMap(target, map[string]interface{}{"name": "nested"}) + if target.Inner.Name != "nested" { + t.Errorf("Inner.Name = %q, want %q", target.Inner.Name, "nested") + } +} + +func TestNormalizeValue(t *testing.T) { + if got := normalizeValue([]byte("hello")); got != "hello" { + t.Errorf("normalizeValue([]byte) = %v, want %q", got, "hello") + } + if got := normalizeValue(42); got != 42 { + t.Errorf("normalizeValue(42) = %v, want 42", got) + } + if got := normalizeValue(nil); got != nil { + t.Errorf("normalizeValue(nil) = %v, want nil", got) + } +} + +func TestToFloat64(t *testing.T) { + cases := []struct { + in interface{} + want float64 + }{ + {float64(1.5), 1.5}, + {float32(2.5), 2.5}, + {int(3), 3}, + {int32(4), 4}, + {int64(5), 5}, + {"6.5", 6.5}, + {"not-a-number", 0}, + {nil, 0}, + } + for _, tc := range cases { + if got := toFloat64(tc.in); got != tc.want { + t.Errorf("toFloat64(%v) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestSetFieldValue_unsettableFieldIsNoop(t *testing.T) { + type s struct{ unexported string } + v := reflect.ValueOf(&s{}).Elem().Field(0) + setFieldValue(v, "value") // must not panic despite CanSet() == false +} diff --git a/internal/db/repository/repository_integration_test.go b/internal/db/repository/repository_integration_test.go new file mode 100644 index 0000000..bdd6046 --- /dev/null +++ b/internal/db/repository/repository_integration_test.go @@ -0,0 +1,275 @@ +//go:build integration + +package repository_test + +import ( + "context" + "errors" + "log" + "os" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "github.com/thd-spatial-ai/ignis/internal/config" + "github.com/thd-spatial-ai/ignis/internal/db/repository" + "github.com/thd-spatial-ai/ignis/internal/utils" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var testPool *pgxpool.Pool + +// TestMain starts a single Postgres container for the whole package and seeds +// two fixture tables: +// +// - tabula.germany: quoted, mixed-case columns ("Code_BuildingVariant", ...) - +// the shape TableConstructor actually produces in production, and what +// TabulaRepository's quoted queries expect. +// - public.building_fixture: unquoted lowercase columns - what +// BuildingRepository.GetByBuildingCode's literal (unquoted) query expects. +// NOTE: these two shapes don't match. BuildingRepository is not wired up +// to any handler in production (see internal/service.NewIgnisServiceWithDB, +// which nothing calls), so this mismatch has never surfaced as a bug - +// but querying a real TableConstructor-created table with +// GetByBuildingCode would return "no data found" today. This fixture +// locks in BuildingRepository's current, documented behaviour rather than +// silently changing it. +func TestMain(m *testing.M) { + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "postgres:17-alpine", + Env: map[string]string{ + "POSTGRES_DB": "postgres", + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + }, + ExposedPorts: []string{"5432/tcp"}, + WaitingFor: wait.ForLog("database system is ready to accept connections").AsRegexp(), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + log.Fatalf("failed to start Postgres container: %v", err) + } + defer container.Terminate(ctx) + + host, err := container.Host(ctx) + if err != nil { + log.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "5432") + if err != nil { + log.Fatalf("failed to get container port: %v", err) + } + + connStr := utils.BuildConnectionString(config.Config{ + DB: &config.DBConfig{User: "test", Password: "test", Host: host, Port: port.Port(), Name: "postgres", SSLMode: "disable"}, + }) + + pool, err := waitForPool(ctx, connStr) + if err != nil { + log.Fatalf("Postgres not ready: %v", err) + } + testPool = pool + defer testPool.Close() + + if err := seedFixtures(ctx, testPool); err != nil { + log.Fatalf("failed to seed fixtures: %v", err) + } + + os.Exit(m.Run()) +} + +// waitForPool retries ConnectPool for up to 15s: the postgres image restarts +// once after its "ready" log line, so the first real connection attempts can +// be reset. +func waitForPool(ctx context.Context, connStr string) (*pgxpool.Pool, error) { + deadline := time.Now().Add(15 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + pool, err := utils.ConnectPool(ctx, connStr) + if err == nil { + return pool, nil + } + lastErr = err + time.Sleep(300 * time.Millisecond) + } + return nil, lastErr +} + +func seedFixtures(ctx context.Context, pool *pgxpool.Pool) error { + statements := []string{ + `CREATE SCHEMA IF NOT EXISTS tabula`, + `CREATE TABLE tabula.germany ( + id SERIAL PRIMARY KEY, + "Code_BuildingVariant" VARCHAR, + "A_Roof_1" REAL, + "HeatingDays" INTEGER, + "q_h_nd" REAL + )`, + `INSERT INTO tabula.germany ("Code_BuildingVariant", "A_Roof_1", "HeatingDays", "q_h_nd") VALUES + ('DE.N.SFH.01.Gen', 75.5, 185, 123.45), + ('DE.N.SFH.01.ReEx', 75.5, 185, 98.70), + ('DE.N.MFH.01.Gen', 200.0, 185, 88.10)`, + `CREATE TABLE building_fixture ( + id SERIAL PRIMARY KEY, + code_buildingvariant VARCHAR, + a_roof_1 REAL + )`, + `INSERT INTO building_fixture (code_buildingvariant, a_roof_1) VALUES ('DE.N.SFH.01.Gen', 75.5)`, + } + for _, stmt := range statements { + if _, err := pool.Exec(ctx, stmt); err != nil { + return err + } + } + return nil +} + +// --- TabulaRepository --- + +func TestTabulaRepository_ListVariants_success(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + variants, err := r.ListVariants(context.Background(), "germany") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"DE.N.MFH.01.Gen", "DE.N.SFH.01.Gen", "DE.N.SFH.01.ReEx"} + if len(variants) != len(want) { + t.Fatalf("variants = %v, want %v", variants, want) + } + for i := range want { + if variants[i] != want[i] { + t.Errorf("variants[%d] = %q, want %q", i, variants[i], want[i]) + } + } +} + +func TestTabulaRepository_ListVariants_queryError(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + _, err := r.ListVariants(context.Background(), "nonexistent_table") + if err == nil { + t.Fatal("expected error for nonexistent table") + } +} + +func TestTabulaRepository_MatchVariants_success(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + codes, err := r.MatchVariants(context.Background(), "germany", "DE.N.SFH.01") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(codes) != 2 { + t.Fatalf("codes = %v, want 2 SFH.01 variants", codes) + } +} + +func TestTabulaRepository_MatchVariants_noMatches(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + codes, err := r.MatchVariants(context.Background(), "germany", "DE.N.SFH.99") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(codes) != 0 { + t.Errorf("codes = %v, want none", codes) + } +} + +func TestTabulaRepository_GetVariant_success(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + params, buildingID, expectedQHND, err := r.GetVariant(context.Background(), "germany", "DE.N.SFH.01.Gen") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buildingID != "DE.N.SFH.01.Gen" { + t.Errorf("buildingID = %q, want %q", buildingID, "DE.N.SFH.01.Gen") + } + if expectedQHND != float64(float32(123.45)) { + t.Errorf("expectedQHND = %v, want %v", expectedQHND, float64(float32(123.45))) + } + if params.BasicParameters.Envelope.A_Roof_1 != float64(float32(75.5)) { + t.Errorf("A_Roof_1 = %v, want %v", params.BasicParameters.Envelope.A_Roof_1, float64(float32(75.5))) + } + if params.AdvancedParameters.ClimateConditions.HeatingDays != 185 { + t.Errorf("HeatingDays = %d, want 185", params.AdvancedParameters.ClimateConditions.HeatingDays) + } +} + +func TestTabulaRepository_GetVariant_notFound(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + _, _, _, err := r.GetVariant(context.Background(), "germany", "DE.N.SFH.99.Gen") + if !errors.Is(err, repository.ErrVariantNotFound) { + t.Errorf("err = %v, want ErrVariantNotFound", err) + } +} + +func TestTabulaRepository_GetVariant_queryError(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + _, _, _, err := r.GetVariant(context.Background(), "nonexistent_table", "DE.N.SFH.01.Gen") + if err == nil { + t.Fatal("expected error for nonexistent table") + } + if errors.Is(err, repository.ErrVariantNotFound) { + t.Error("a query error against a missing table should not be reported as ErrVariantNotFound") + } +} + +// --- BuildingRepository --- +// See the TestMain doc comment: this repository's unquoted query only matches +// a table created with unquoted (hence lowercased) column names, which is +// not what TableConstructor actually produces - building_fixture mirrors +// BuildingRepository's own literal expectation, not production reality. + +// TestBuildingRepository_GetByBuildingCode_success finds a row (the unquoted +// WHERE clause matches building_fixture's unquoted lowercase columns) but +// documents a second, compounding mismatch: populateStructFromMap looks up +// scanned values by the mixed-case JSON tag ("A_Roof_1"), while Postgres +// returns unquoted-created columns lowercased ("a_roof_1") in +// FieldDescriptions - so the map lookup misses and the field stays zero even +// on a "successful" (no error) call. Combined with the quoted-table mismatch +// above, GetByBuildingCode cannot correctly populate a struct from either +// table shape as currently written; this is presumably why it's unused in +// production (see TestMain's doc comment). +func TestBuildingRepository_GetByBuildingCode_success(t *testing.T) { + r := repository.NewBuildingRepository(testPool, "") + params, err := r.GetByBuildingCode(context.Background(), "building_fixture", "DE.N.SFH.01.Gen") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.BasicParameters.Envelope.A_Roof_1 != 0 { + t.Errorf("A_Roof_1 = %v, want 0 (case-mismatched column lookup silently fails to populate) - if this now populates, the bug has been fixed and this test should be updated to assert 75.5", params.BasicParameters.Envelope.A_Roof_1) + } +} + +func TestBuildingRepository_GetByBuildingCode_notFound(t *testing.T) { + r := repository.NewBuildingRepository(testPool, "") + _, err := r.GetByBuildingCode(context.Background(), "building_fixture", "DE.N.SFH.99.Gen") + if err == nil { + t.Fatal("expected error for unknown building code") + } +} + +func TestBuildingRepository_GetByBuildingCode_queryError(t *testing.T) { + r := repository.NewBuildingRepository(testPool, "") + _, err := r.GetByBuildingCode(context.Background(), "nonexistent_table", "DE.N.SFH.01.Gen") + if err == nil { + t.Fatal("expected error for nonexistent table") + } +} + +// mismatchAgainstProductionShape documents (rather than silently fixes) the +// case-sensitivity gap: BuildingRepository's query cannot find a row in a +// table shaped like TableConstructor's real output. +func TestBuildingRepository_GetByBuildingCode_mismatchAgainstProductionShape(t *testing.T) { + r := repository.NewBuildingRepository(testPool, "tabula") + _, err := r.GetByBuildingCode(context.Background(), "germany", "DE.N.SFH.01.Gen") + if err == nil { + t.Fatal("expected GetByBuildingCode's unquoted query to fail to find a row in a quoted-column production-shaped table - if this now passes, the case-sensitivity mismatch has been fixed and this test (and its doc comment) should be updated") + } +} diff --git a/internal/db/repository/tabula_repository_test.go b/internal/db/repository/tabula_repository_test.go new file mode 100644 index 0000000..ca6c4a9 --- /dev/null +++ b/internal/db/repository/tabula_repository_test.go @@ -0,0 +1,31 @@ +package repository + +import "testing" + +func TestTabulaRepository_qualifyTable(t *testing.T) { + cases := []struct { + schema, table, want string + }{ + {"tabula", "germany", `"tabula"."germany"`}, + {"", "germany", `"germany"`}, + } + for _, tc := range cases { + r := NewTabulaRepository(nil, tc.schema) + if got := r.qualifyTable(tc.table); got != tc.want { + t.Errorf("qualifyTable(schema=%q, table=%q) = %q, want %q", tc.schema, tc.table, got, tc.want) + } + } +} + +func TestNewTabulaRepository(t *testing.T) { + r := NewTabulaRepository(nil, "tabula") + if r.schema != "tabula" { + t.Errorf("schema = %q, want %q", r.schema, "tabula") + } +} + +func TestErrVariantNotFound(t *testing.T) { + if ErrVariantNotFound.Error() != "tabula variant not found" { + t.Errorf("ErrVariantNotFound.Error() = %q", ErrVariantNotFound.Error()) + } +} diff --git a/internal/db/table_constructor_integration_test.go b/internal/db/table_constructor_integration_test.go new file mode 100644 index 0000000..6e48c59 --- /dev/null +++ b/internal/db/table_constructor_integration_test.go @@ -0,0 +1,183 @@ +//go:build integration + +package importer_test + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "github.com/thd-spatial-ai/ignis/internal/config" + importer "github.com/thd-spatial-ai/ignis/internal/db" + "github.com/thd-spatial-ai/ignis/internal/utils" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/xuri/excelize/v2" +) + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "postgres:17-alpine", + Env: map[string]string{ + "POSTGRES_DB": "postgres", + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + }, + ExposedPorts: []string{"5432/tcp"}, + WaitingFor: wait.ForLog("database system is ready to accept connections").AsRegexp(), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + log.Fatalf("failed to start Postgres container: %v", err) + } + defer container.Terminate(ctx) + + host, err := container.Host(ctx) + if err != nil { + log.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "5432") + if err != nil { + log.Fatalf("failed to get container port: %v", err) + } + + connStr := utils.BuildConnectionString(config.Config{ + DB: &config.DBConfig{User: "test", Password: "test", Host: host, Port: port.Port(), Name: "postgres", SSLMode: "disable"}, + }) + + pool, err := waitForPool(ctx, connStr) + if err != nil { + log.Fatalf("Postgres not ready: %v", err) + } + testPool = pool + defer testPool.Close() + + os.Exit(m.Run()) +} + +func waitForPool(ctx context.Context, connStr string) (*pgxpool.Pool, error) { + deadline := time.Now().Add(15 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + pool, err := utils.ConnectPool(ctx, connStr) + if err == nil { + return pool, nil + } + lastErr = err + time.Sleep(300 * time.Millisecond) + } + return nil, lastErr +} + +// writeFixtureWorkbook creates a minimal xlsx with a "Calc.Set.Building" +// sheet in the same layout as the real TABULA workbook: header row 1, data +// type row 6, data starting row 13 - matching what extractHeaders/ +// extractCountryCodes (see table_constructor.go) expect. +func writeFixtureWorkbook(t *testing.T) string { + t.Helper() + f := excelize.NewFile() + sheet := "Calc.Set.Building" + if _, err := f.NewSheet(sheet); err != nil { + t.Fatal(err) + } + + headers := []string{"Code_BuildingVariant", "Code_Country", "Code_ComplexRoof", "A_Roof_1", "HeatingDays"} + dataTypes := []string{"VarChar", "VarChar", "VarChar", "Real", "Integer"} + for col, h := range headers { + cell, _ := excelize.CoordinatesToCellName(col+1, 1) + f.SetCellValue(sheet, cell, h) + } + for col, dt := range dataTypes { + cell, _ := excelize.CoordinatesToCellName(col+1, 6) + f.SetCellValue(sheet, cell, dt) + } + + countries := []string{"DE", "AT"} + for i := 0; i < 20; i++ { + row := 13 + i // rows[12] in 0-indexed = row 13 in 1-indexed sheet + values := []interface{}{ + fmt.Sprintf("%s.N.SFH.%02d", countries[i%len(countries)], i), + countries[i%len(countries)], + "yes", + 45.5, + 180, + } + for col, v := range values { + cell, _ := excelize.CoordinatesToCellName(col+1, row) + f.SetCellValue(sheet, cell, v) + } + } + + path := filepath.Join(t.TempDir(), "fixture.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatal(err) + } + return path +} + +func TestTableConstructor_Run_createsAndPopulatesCountryTables(t *testing.T) { + xlsxPath := writeFixtureWorkbook(t) + cfg := &config.Config{ + Data: &config.DataPaths{ExcelFile: xlsxPath}, + DB: &config.DBConfig{Schemas: &config.Schemas{Tabula: "tc_test"}}, + } + + tc := importer.NewTableConstructor(testPool, cfg) + if err := tc.Run(); err != nil { + t.Fatalf("Run() unexpected error: %v", err) + } + + ctx := context.Background() + + var tableCount int + err := testPool.QueryRow(ctx, + `SELECT count(*) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2`, + "tc_test", "germany", + ).Scan(&tableCount) + if err != nil { + t.Fatalf("checking table existence: %v", err) + } + if tableCount != 1 { + t.Fatalf("expected tc_test.germany to exist, tableCount=%d", tableCount) + } + + var rowCount int + if err := testPool.QueryRow(ctx, `SELECT count(*) FROM tc_test.germany`).Scan(&rowCount); err != nil { + t.Fatalf("counting rows: %v", err) + } + if rowCount != 10 { // half of the 20 fixture rows are DE + t.Errorf("row count = %d, want 10", rowCount) + } + + var aRoof1 float64 + if err := testPool.QueryRow(ctx, `SELECT "A_Roof_1" FROM tc_test.germany LIMIT 1`).Scan(&aRoof1); err != nil { + t.Fatalf("reading A_Roof_1: %v", err) + } + if aRoof1 != 45.5 { + t.Errorf("A_Roof_1 = %v, want 45.5", aRoof1) + } +} + +func TestTableConstructor_Run_missingWorkbook_returnsError(t *testing.T) { + cfg := &config.Config{ + Data: &config.DataPaths{ExcelFile: "/nonexistent/workbook.xlsx"}, + DB: &config.DBConfig{Schemas: &config.Schemas{Tabula: "tc_test"}}, + } + tc := importer.NewTableConstructor(testPool, cfg) + if err := tc.Run(); err == nil { + t.Fatal("expected error for missing workbook") + } +} diff --git a/internal/db/table_constructor_test.go b/internal/db/table_constructor_test.go new file mode 100644 index 0000000..e45a273 --- /dev/null +++ b/internal/db/table_constructor_test.go @@ -0,0 +1,193 @@ +package importer + +import ( + "fmt" + "sort" + "testing" + + "github.com/thd-spatial-ai/ignis/internal/config" +) + +// buildFixtureRows constructs a minimal TABULA-shaped [][]string: a header +// row, a data-type row at index 5, and 20 data rows (indices 12-31) - the +// same layout extractHeaders/getDropdownValues/extractCountryCodes expect +// from the real "Calc.Set.Building" sheet. +func buildFixtureRows() [][]string { + rows := make([][]string, 32) + for i := range rows { + rows[i] = []string{} + } + rows[0] = []string{"Code_BuildingVariant", "Code_Country", "Code_ComplexRoof", "Code_Custom", "A_Roof_1", "HeatingDays"} + rows[5] = []string{"VarChar", "VarChar", "VarChar", "VarChar", "Real", "Integer"} + + countries := []string{"DE", "AT", "FR"} + customVals := []string{"X", "Y"} + for i := 12; i < 32; i++ { + rows[i] = []string{ + fmt.Sprintf("DE.N.SFH.%02d", i), + countries[i%len(countries)], + "yes", + customVals[i%len(customVals)], + "45.5", + "180", + } + } + return rows +} + +func newTestTableConstructor() *TableConstructor { + return NewTableConstructor(nil, &config.Config{ + DB: &config.DBConfig{Schemas: &config.Schemas{Tabula: "tabula"}}, + }) +} + +func TestExtractHeaders_populatesHeaderInfo(t *testing.T) { + tc := newTestTableConstructor() + tc.extractHeaders(buildFixtureRows()) + + info, ok := tc.headers["A_Roof_1"] + if !ok { + t.Fatal(`expected "A_Roof_1" in headers`) + } + if info.CellIndex != 4 || info.CellDataType != "Real" { + t.Errorf("A_Roof_1 header = %+v, want CellIndex=4 CellDataType=Real", info) + } + + info, ok = tc.headers["HeatingDays"] + if !ok || info.CellDataType != "Integer" { + t.Errorf("HeatingDays header = %+v, want CellDataType=Integer", info) + } +} + +func TestExtractHeaders_emptyHeaderNameSkipped(t *testing.T) { + tc := newTestTableConstructor() + rows := buildFixtureRows() + rows[0] = append(rows[0], "") + tc.extractHeaders(rows) + + if _, ok := tc.headers[""]; ok { + t.Error("expected empty-string header to be skipped") + } +} + +func TestExtractHeaders_missingOrEmptyDataTypeDefaultsToVarChar(t *testing.T) { + tc := newTestTableConstructor() + rows := buildFixtureRows() + rows[5][0] = "" // Code_BuildingVariant's data type cell is blank + tc.extractHeaders(rows) + + if tc.headers["Code_BuildingVariant"].CellDataType != "VarChar" { + t.Errorf("CellDataType = %q, want VarChar fallback", tc.headers["Code_BuildingVariant"].CellDataType) + } +} + +func TestExtractHeaders_shortRowsIsNoop(t *testing.T) { + tc := newTestTableConstructor() + tc.extractHeaders([][]string{{"only one row"}}) + if len(tc.headers) != 0 { + t.Errorf("expected no headers extracted from <6 rows, got %d", len(tc.headers)) + } +} + +func TestExtractHeaders_addsDropdownColumn(t *testing.T) { + tc := newTestTableConstructor() + tc.extractHeaders(buildFixtureRows()) + + info, ok := tc.headers["Code_Custom_val_data"] + if !ok { + t.Fatal(`expected "Code_Custom_val_data" dropdown column to be added`) + } + if info.CellDataType != "List" { + t.Errorf("CellDataType = %q, want List", info.CellDataType) + } + want := []string{"X", "Y"} + sort.Strings(info.CellDataValidations) + if len(info.CellDataValidations) != len(want) { + t.Fatalf("CellDataValidations = %v, want %v", info.CellDataValidations, want) + } + for i, v := range want { + if info.CellDataValidations[i] != v { + t.Errorf("CellDataValidations = %v, want %v", info.CellDataValidations, want) + } + } +} + +func TestGetDropdownValues_knownColumnsAreNotResampled(t *testing.T) { + tc := newTestTableConstructor() + rows := buildFixtureRows() + dropdowns := tc.getDropdownValues(rows, rows[0]) + + want := []string{"yes", "no"} + got := dropdowns["Code_ComplexRoof"] + if len(got) != len(want) { + t.Fatalf("Code_ComplexRoof = %v, want the hardcoded %v (not resampled)", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Code_ComplexRoof = %v, want %v", got, want) + } + } +} + +func TestGetDropdownValues_tooManyDistinctValuesIsExcluded(t *testing.T) { + tc := newTestTableConstructor() + rows := make([][]string, 32) + for i := range rows { + rows[i] = []string{} + } + rows[0] = []string{"Code_HighCardinality"} + rows[5] = []string{"VarChar"} + for i := 12; i < 32; i++ { + // 20 rows, each with a unique value -> exceeds the len<=15 cutoff. + rows[i] = []string{fmt.Sprintf("val%d", i)} + } + + dropdowns := tc.getDropdownValues(rows, rows[0]) + if _, exists := dropdowns["Code_HighCardinality"]; exists { + t.Error("expected column with >15 distinct sampled values to be excluded") + } +} + +func TestExtractCountryCodes_collectsValidCodesSorted(t *testing.T) { + tc := newTestTableConstructor() + tc.extractCountryCodes(buildFixtureRows()) + + want := []string{"AT", "DE", "FR"} + if len(tc.countryCodes) != len(want) { + t.Fatalf("countryCodes = %v, want %v", tc.countryCodes, want) + } + for i := range want { + if tc.countryCodes[i] != want[i] { + t.Errorf("countryCodes = %v, want %v", tc.countryCodes, want) + } + } +} + +func TestExtractCountryCodes_invalidCodesIgnored(t *testing.T) { + tc := newTestTableConstructor() + rows := buildFixtureRows() + for i := 12; i < 32; i++ { + rows[i][1] = "ZZ" // not a real ISO2 code + } + tc.extractCountryCodes(rows) + + if len(tc.countryCodes) != 0 { + t.Errorf("countryCodes = %v, want none for an all-invalid country column", tc.countryCodes) + } +} + +func TestClose_nilXlsxFileIsNoop(t *testing.T) { + tc := newTestTableConstructor() + if err := tc.close(); err != nil { + t.Errorf("close() with nil xlsxFile: unexpected error %v", err) + } +} + +func TestLoadWorkbook_missingFile_returnsError(t *testing.T) { + tc := NewTableConstructor(nil, &config.Config{ + Data: &config.DataPaths{ExcelFile: "/nonexistent/path/workbook.xlsx"}, + }) + if err := tc.loadWorkbook(); err == nil { + t.Error("expected error opening a nonexistent workbook") + } +} diff --git a/internal/models/errors_test.go b/internal/models/errors_test.go new file mode 100644 index 0000000..8cdf811 --- /dev/null +++ b/internal/models/errors_test.go @@ -0,0 +1,47 @@ +package models + +import "testing" + +func TestValidationError(t *testing.T) { + err := NewValidationError("A_Roof_1", "must be positive") + want := "validation error on field 'A_Roof_1': must be positive" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } + if err.Field != "A_Roof_1" || err.Message != "must be positive" { + t.Errorf("unexpected fields: %+v", err) + } +} + +func TestCalculationError(t *testing.T) { + err := NewCalculationError("Level 7", "division by zero") + want := "calculation error at level 'Level 7': division by zero" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } + if err.Level != "Level 7" || err.Message != "division by zero" { + t.Errorf("unexpected fields: %+v", err) + } +} + +func TestDatabaseError(t *testing.T) { + err := NewDatabaseError("query", "connection refused") + want := "database error during 'query': connection refused" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } + if err.Operation != "query" || err.Message != "connection refused" { + t.Errorf("unexpected fields: %+v", err) + } +} + +func TestNotFoundError(t *testing.T) { + err := NewNotFoundError("variant", "DE.N.SFH.01") + want := "variant with ID 'DE.N.SFH.01' not found" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } + if err.Resource != "variant" || err.ID != "DE.N.SFH.01" { + t.Errorf("unexpected fields: %+v", err) + } +} diff --git a/internal/models/field_metadata_test.go b/internal/models/field_metadata_test.go new file mode 100644 index 0000000..d73565a --- /dev/null +++ b/internal/models/field_metadata_test.go @@ -0,0 +1,39 @@ +package models + +import "testing" + +// TestAllFieldMetadata_wellFormed guards against copy-paste mistakes when new +// fields are added: duplicate keys silently overwrite each other client-side, +// and a missing required attribute leaves clients with a blank label/path. +func TestAllFieldMetadata_wellFormed(t *testing.T) { + if len(AllFieldMetadata) == 0 { + t.Fatal("AllFieldMetadata is empty") + } + + seen := make(map[string]bool, len(AllFieldMetadata)) + for _, f := range AllFieldMetadata { + if f.Key == "" { + t.Errorf("field with empty Key (Path=%q)", f.Path) + } + if seen[f.Key] { + t.Errorf("duplicate Key %q", f.Key) + } + seen[f.Key] = true + + if f.Group == "" { + t.Errorf("field %q: empty Group", f.Key) + } + if f.Path == "" { + t.Errorf("field %q: empty Path", f.Key) + } + if f.Label == "" { + t.Errorf("field %q: empty Label", f.Key) + } + if f.SimpleDescription == "" { + t.Errorf("field %q: empty SimpleDescription", f.Key) + } + if f.ExpertDescription == "" { + t.Errorf("field %q: empty ExpertDescription", f.Key) + } + } +} diff --git a/internal/service/hdcp_service_test.go b/internal/service/hdcp_service_test.go new file mode 100644 index 0000000..6bd28e6 --- /dev/null +++ b/internal/service/hdcp_service_test.go @@ -0,0 +1,134 @@ +package service + +import ( + "context" + "log" + "os" + "strings" + "testing" + + "github.com/thd-spatial-ai/ignis/internal/hdcp" + "github.com/thd-spatial-ai/ignis/internal/models" +) + +// newTestParams returns a fully initialised TabulaBuildingParameters with all +// nested pointers non-nil, mirroring internal/calc's own test fixture. +func newTestParams() *models.TabulaBuildingParameters { + return &models.TabulaBuildingParameters{ + BasicParameters: &models.BasicParameters{ + BuildingAppearance: &models.BuildingThematic{}, + Envelope: &models.Envelope{}, + }, + AdvancedParameters: &models.AdvancedParameters{ + AirInfiltration: &models.AirInfiltration{}, + ClimateConditions: &models.ClimateConditions{}, + Uvalues: &models.Uvalues{}, + Insulation: &models.InsulationThicknesses{}, + SolarGains: &models.SolarGains{}, + ThermalBridges: &models.ThermalBridgeParameters{}, + HeatLosses: &models.TransmissionHeatLoss{}, + ThermalResistances: &models.ThermalResistances{}, + InsulationMeasures: &models.InsulationPredefinedMeasures{}, + ActualInsulation: &models.ActualInsulationThicknesses{}, + HeatTransfer: &models.HeatTransferCoefficients{}, + PredefinedCodes: &models.PredefinedCodes{F_Corr_CeilingHeight: 1.0}, + MeasureTypes: &models.MeasureTypeCodes{}, + SolarTransmittance: &models.SolarEnergyTransmittance{}, + MeasureFractions: &models.MeasureAreaFractions{}, + AdditionalResistances: &models.AdditionalThermalResistance{}, + }, + } +} + +func TestNewIgnisService(t *testing.T) { + s := NewIgnisService() + if s.logger == nil { + t.Error("expected non-nil logger") + } + if s.repository != nil { + t.Error("expected nil repository when constructed without a DB") + } +} + +func TestNewIgnisServiceWithDB(t *testing.T) { + // NewIgnisServiceWithDB only stores the pool; it never dials, so nil is safe here. + s := NewIgnisServiceWithDB(nil, "tabula") + if s.repository == nil { + t.Error("expected non-nil repository when constructed with a DB") + } +} + +func TestNewIgnisServiceWithLogger(t *testing.T) { + logger := hdcp.NewLogger(log.New(os.Stdout, "", 0)) + s := NewIgnisServiceWithLogger(logger) + if s.logger != logger { + t.Error("expected the provided logger to be used") + } +} + +func TestGetBuildingByCode_noRepository_returnsError(t *testing.T) { + s := NewIgnisService() + _, err := s.GetBuildingByCode(context.Background(), "germany", "DE.N.SFH.01.Gen") + if err == nil { + t.Fatal("expected error when repository is not initialized") + } + if !strings.Contains(err.Error(), "repository not initialized") { + t.Errorf("error = %q, want mention of uninitialized repository", err.Error()) + } +} + +func TestNormalizeCountryName(t *testing.T) { + cases := []struct{ in, want string }{ + {"Germany", "germany"}, + {" United Kingdom ", "united_kingdom"}, + {"Czech-Republic", "czech_republic"}, + } + for _, tc := range cases { + if got := normalizeCountryName(tc.in); got != tc.want { + t.Errorf("normalizeCountryName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCalculateHeatingDemand_success(t *testing.T) { + s := NewIgnisService() + // An all-zero building is a division-by-zero case in the pipeline's own + // arithmetic (see internal/hdcp's own zero-input test) and legitimately + // yields NaN, not an error - the point here is that it completes without panicking. + _, err := s.CalculateHeatingDemand(newTestParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCalculateHeatingDemand_pipelineError(t *testing.T) { + s := NewIgnisService() + // nil params trigger a nil-pointer panic inside the pipeline, which + // handleError converts into a returned error rather than a process crash. + _, err := s.CalculateHeatingDemand(nil) + if err == nil { + t.Fatal("expected error from pipeline when params are nil") + } +} + +func TestCalculateHeatingDemandWithDetails_success(t *testing.T) { + s := NewIgnisService() + pipeline, err := s.CalculateHeatingDemandWithDetails(newTestParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pipeline == nil || pipeline.Lvl17 == nil { + t.Error("expected fully populated pipeline with Lvl17 set") + } +} + +func TestCalculateHeatingDemandWithDetails_pipelineError(t *testing.T) { + s := NewIgnisService() + pipeline, err := s.CalculateHeatingDemandWithDetails(nil) + if err == nil { + t.Fatal("expected error from pipeline when params are nil") + } + if pipeline != nil { + t.Error("expected nil pipeline on error") + } +} diff --git a/internal/utils/database_integration_test.go b/internal/utils/database_integration_test.go new file mode 100644 index 0000000..1ab24ed --- /dev/null +++ b/internal/utils/database_integration_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package utils_test + +import ( + "context" + "fmt" + "log" + "os" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "github.com/thd-spatial-ai/ignis/internal/config" + "github.com/thd-spatial-ai/ignis/internal/utils" +) + +var ( + testHost string + testPort string +) + +func TestMain(m *testing.M) { + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "postgres:17-alpine", + Env: map[string]string{ + "POSTGRES_DB": "postgres", + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + }, + ExposedPorts: []string{"5432/tcp"}, + WaitingFor: wait.ForLog("database system is ready to accept connections").AsRegexp(), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + log.Fatalf("failed to start Postgres container: %v", err) + } + defer container.Terminate(ctx) + + testHost, err = container.Host(ctx) + if err != nil { + log.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "5432") + if err != nil { + log.Fatalf("failed to get container port: %v", err) + } + testPort = port.Port() + + // The postgres image restarts once after the "ready to accept connections" + // log line (re-init with the final config), so the first few real + // connection attempts can be reset. Wait it out before running any test. + if err := waitUntilReady(ctx); err != nil { + log.Fatalf("Postgres not ready: %v", err) + } + + os.Exit(m.Run()) +} + +func waitUntilReady(ctx context.Context) error { + deadline := time.Now().Add(15 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + pool, err := utils.ConnectPool(ctx, testConnString()) + if err == nil { + pool.Close() + return nil + } + lastErr = err + time.Sleep(300 * time.Millisecond) + } + return lastErr +} + +func testConnString() string { + cfg := config.Config{ + DB: &config.DBConfig{ + User: "test", + Password: "test", + Host: testHost, + Port: testPort, + Name: "postgres", + SSLMode: "disable", + }, + } + return utils.BuildConnectionString(cfg) +} + +func TestConnectPool_success(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := utils.ConnectPool(ctx, testConnString()) + if err != nil { + t.Fatalf("ConnectPool: unexpected error: %v", err) + } + defer pool.Close() + + var result int + if err := pool.QueryRow(ctx, "SELECT 1").Scan(&result); err != nil { + t.Fatalf("query through connected pool failed: %v", err) + } + if result != 1 { + t.Errorf("SELECT 1 = %d, want 1", result) + } +} + +// TestBuildConnectionString_roundTripsToRealConnection is an integration-level +// sanity check that BuildConnectionString's format is actually accepted by +// pgx/Postgres, not just string-equality-tested (see database_test.go). +func TestBuildConnectionString_roundTripsToRealConnection(t *testing.T) { + connStr := fmt.Sprintf("postgres://test:test@%s:%s/postgres?sslmode=disable", testHost, testPort) + if connStr != testConnString() { + t.Fatalf("test helper drifted from BuildConnectionString's actual format: got %q want %q", testConnString(), connStr) + } +} diff --git a/internal/utils/database_test.go b/internal/utils/database_test.go new file mode 100644 index 0000000..e2fb6ad --- /dev/null +++ b/internal/utils/database_test.go @@ -0,0 +1,60 @@ +package utils + +import ( + "context" + "testing" + "time" + + "github.com/thd-spatial-ai/ignis/internal/config" +) + +func TestBuildConnectionString(t *testing.T) { + cfg := config.Config{ + DB: &config.DBConfig{ + User: "tester", + Password: "secret", + Host: "db.internal", + Port: "5433", + Name: "ignis_test", + SSLMode: "disable", + }, + } + + got := BuildConnectionString(cfg) + want := "postgres://tester:secret@db.internal:5433/ignis_test?sslmode=disable" + if got != want { + t.Errorf("BuildConnectionString = %q, want %q", got, want) + } +} + +func TestConnectPool_invalidConnString(t *testing.T) { + ctx := context.Background() + pool, err := ConnectPool(ctx, "not-a-valid-connection-string") + if err == nil { + if pool != nil { + pool.Close() + } + t.Fatal("expected error for malformed connection string, got nil") + } + if pool != nil { + t.Error("expected nil pool on error") + } +} + +func TestConnectPool_pingFailure(t *testing.T) { + // A well-formed connection string pointing at a port nothing listens on: + // pgxpool.New succeeds (it doesn't dial), so this exercises the Ping failure branch. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + pool, err := ConnectPool(ctx, "postgres://user:pass@127.0.0.1:1/nonexistent?sslmode=disable&connect_timeout=1") + if err == nil { + if pool != nil { + pool.Close() + } + t.Fatal("expected ping error for unreachable database, got nil") + } + if pool != nil { + t.Error("expected nil pool on ping failure") + } +} diff --git a/internal/utils/logger_test.go b/internal/utils/logger_test.go new file mode 100644 index 0000000..ecb5dac --- /dev/null +++ b/internal/utils/logger_test.go @@ -0,0 +1,181 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +// withTempCwd switches to a fresh temp directory for the duration of the +// test so InitLogger's "logs/" directory and log file don't leak into the repo. +func withTempCwd(t *testing.T) { + t.Helper() + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatal(err) + } + }) +} + +func TestInitLogger_infoLevel(t *testing.T) { + withTempCwd(t) + t.Setenv("LOG_LEVEL", "INFO") + + InitLogger() + + if GetLogLevel() != LogLevelInfo { + t.Errorf("GetLogLevel() = %v, want LogLevelInfo", GetLogLevel()) + } + if IsDebugEnabled() { + t.Error("IsDebugEnabled() = true at INFO level, want false") + } + if _, err := os.Stat("logs"); err != nil { + t.Errorf("expected logs/ directory to be created: %v", err) + } +} + +func TestInitLogger_debugLevel(t *testing.T) { + withTempCwd(t) + t.Setenv("LOG_LEVEL", "DEBUG") + + InitLogger() + + if GetLogLevel() != LogLevelDebug { + t.Errorf("GetLogLevel() = %v, want LogLevelDebug", GetLogLevel()) + } + if !IsDebugEnabled() { + t.Error("IsDebugEnabled() = false at DEBUG level, want true") + } +} + +func TestInitLogger_warnAndErrorLevels(t *testing.T) { + cases := map[string]LogLevel{ + "WARN": LogLevelWarn, + "WARNING": LogLevelWarn, + "ERROR": LogLevelError, + } + for env, want := range cases { + t.Run(env, func(t *testing.T) { + withTempCwd(t) + t.Setenv("LOG_LEVEL", env) + InitLogger() + if GetLogLevel() != want { + t.Errorf("GetLogLevel() = %v, want %v", GetLogLevel(), want) + } + }) + } +} + +func TestInitLogger_unknownLevelDefaultsToInfo(t *testing.T) { + withTempCwd(t) + t.Setenv("LOG_LEVEL", "NOT_A_LEVEL") + + InitLogger() + + if GetLogLevel() != LogLevelInfo { + t.Errorf("GetLogLevel() = %v, want LogLevelInfo for unknown value", GetLogLevel()) + } +} + +func TestInitLogger_logDirBlockedByFile_fallsBackToConsole(t *testing.T) { + withTempCwd(t) + // Create a plain file named "logs" so os.MkdirAll("logs", ...) fails, + // exercising InitLogger's "console only" fallback branch. + if err := os.WriteFile("logs", []byte("not a directory"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("LOG_LEVEL", "INFO") + + InitLogger() // must not panic + + if Info == nil || Error == nil { + t.Error("expected loggers to be initialized even when log dir creation fails") + } +} + +func TestInitLogger_logFileUnwritable_fallsBackToConsole(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("running as root: permission checks don't apply") + } + withTempCwd(t) + if err := os.Mkdir("logs", 0555); err != nil { // read-only dir: MkdirAll succeeds, OpenFile inside it fails + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod("logs", 0755) }) // t.TempDir() cleanup needs write access back + + t.Setenv("LOG_LEVEL", "INFO") + + InitLogger() // must not panic + + if Info == nil || Error == nil { + t.Error("expected loggers to be initialized even when log file creation fails") + } +} + +func TestSetLogLevel(t *testing.T) { + withTempCwd(t) + InitLogger() // ensure loggers are non-nil before SetLogLevel logs through them + + SetLogLevel(LogLevelError) + if GetLogLevel() != LogLevelError { + t.Errorf("GetLogLevel() = %v, want LogLevelError", GetLogLevel()) + } + + SetLogLevel(LogLevelInfo) + if GetLogLevel() != LogLevelInfo { + t.Errorf("GetLogLevel() = %v, want LogLevelInfo", GetLogLevel()) + } +} + +func TestGetLogLevelName_allLevels(t *testing.T) { + withTempCwd(t) + InitLogger() + + cases := []struct { + level LogLevel + want string + }{ + {LogLevelDebug, "DEBUG"}, + {LogLevelInfo, "INFO"}, + {LogLevelWarn, "WARN"}, + {LogLevelError, "ERROR"}, + {LogLevel(99), "UNKNOWN"}, + } + for _, tc := range cases { + SetLogLevel(tc.level) // indirectly exercises getLogLevelName via its log line + if GetLogLevel() != tc.level { + t.Errorf("GetLogLevel() = %v, want %v", GetLogLevel(), tc.level) + } + } +} + +func TestInitLogger_writesToDatedLogFile(t *testing.T) { + withTempCwd(t) + t.Setenv("LOG_LEVEL", "INFO") + + InitLogger() + Info.Println("hello from test") + + entries, err := os.ReadDir("logs") + if err != nil { + t.Fatalf("reading logs dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly one log file, got %d", len(entries)) + } + content, err := os.ReadFile(filepath.Join("logs", entries[0].Name())) + if err != nil { + t.Fatal(err) + } + if len(content) == 0 { + t.Error("expected log file to contain the logged message") + } +} diff --git a/internal/utils/tabula_country_helper_test.go b/internal/utils/tabula_country_helper_test.go index b642cdb..33b758b 100644 --- a/internal/utils/tabula_country_helper_test.go +++ b/internal/utils/tabula_country_helper_test.go @@ -59,6 +59,68 @@ func TestCountryToCode_knownCountries(t *testing.T) { } } +func TestCountryToCode_unknownCountry_returnsEmpty(t *testing.T) { + h := NewTabulaCountryHelper() + got := h.CountryToCode("narnia") + if got != "" { + t.Errorf("CountryToCode(\"narnia\") = %q, want empty string", got) + } +} + +func TestGetAllCountryCodes(t *testing.T) { + h := NewTabulaCountryHelper() + codes := h.GetAllCountryCodes() + if len(codes) != len(countryMapping) { + t.Errorf("GetAllCountryCodes returned %d codes, want %d", len(codes), len(countryMapping)) + } + found := false + for _, c := range codes { + if c == "DE" { + found = true + } + } + if !found { + t.Error("GetAllCountryCodes: expected \"DE\" in result") + } +} + +func TestGetAllCountryNames(t *testing.T) { + h := NewTabulaCountryHelper() + names := h.GetAllCountryNames() + if len(names) != len(countryMapping) { + t.Errorf("GetAllCountryNames returned %d names, want %d", len(names), len(countryMapping)) + } + found := false + for _, n := range names { + if n == "germany" { + found = true + } + } + if !found { + t.Error("GetAllCountryNames: expected \"germany\" in result") + } +} + +func TestIsValidCountryCode(t *testing.T) { + h := NewTabulaCountryHelper() + if !h.IsValidCountryCode("de") { + t.Error("IsValidCountryCode(\"de\") = false, want true (case-insensitive)") + } + if h.IsValidCountryCode("XX") { + t.Error("IsValidCountryCode(\"XX\") = true, want false") + } +} + +func TestIsValidCountryName(t *testing.T) { + h := NewTabulaCountryHelper() + if !h.IsValidCountryName("United Kingdom") { + t.Error("IsValidCountryName(\"United Kingdom\") = false, want true (space/case normalised)") + } + if h.IsValidCountryName("narnia") { + t.Error("IsValidCountryName(\"narnia\") = true, want false") + } +} + func TestRoundTrip_codeToCountryToCode(t *testing.T) { h := NewTabulaCountryHelper() // Spot-check that code → country → code round-trips correctly.